agents-can-communicate 0.4.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) 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-gemini-cli/extension/gemini-extension.json +1 -1
  17. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  18. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  19. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  20. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  21. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -1
  22. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +63 -16
  23. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +16 -1
  24. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  25. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +7 -1
  26. package/node_modules/@agents-can-communicate/cli/src/index.mjs +5 -0
  27. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +187 -11
  28. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/diagnostics.mjs +2 -1
  29. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/download.mjs +17 -2
  30. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +14 -6
  31. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/generation.mjs +44 -25
  32. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/install.mjs +46 -29
  33. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance-state.mjs +1 -1
  34. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance-worker.mjs +9 -1
  35. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance.mjs +52 -4
  36. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/management-recovery.mjs +18 -9
  37. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/pins.mjs +125 -0
  38. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/retire.mjs +56 -0
  39. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/staging.mjs +95 -0
  40. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/state.mjs +8 -1
  41. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +18 -8
  42. package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +83 -7
  43. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  44. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  45. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +44 -2
  46. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  47. package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +10 -1
  48. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +55 -6
  49. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  50. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  51. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  52. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  53. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +38 -4
  54. package/package.json +2 -1
@@ -3,6 +3,8 @@ import { lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm }
3
3
  from "node:fs/promises";
4
4
  import path from "node:path";
5
5
 
6
+ import { attachStagingTemp, holdStagedGeneration, releaseStagingHold } from "./staging.mjs";
7
+
6
8
  const NAME = "agents-can-communicate";
7
9
  const STABLE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
8
10
 
@@ -134,34 +136,51 @@ export async function stageOwnGeneration({ packageRoot, managerRoot }) {
134
136
  await directory(generations);
135
137
  const target = path.join(generations, `${manifest.version}-${digest.slice(0, 24)}`);
136
138
  const result = { version: manifest.version, root: target, digest };
137
- if (await existingMatches(target, files)) return result;
138
- const staging = await mkdtemp(path.join(generations, ".staging-"));
139
+ // Held before the check that may return `target` unchanged (it can already
140
+ // be sitting on disk unprotected from an earlier run) and, on the staging
141
+ // path below, before the rename that makes a freshly built `target`
142
+ // visible at all: reclaim must never observe the directory without also
143
+ // observing why it exists. The caller now owns this hold - see its own
144
+ // `hold` on the returned value - and must release it on every exit from
145
+ // its own attempt, once the generation is either published or abandoned.
146
+ // A hold this function itself never hands off (because it throws instead
147
+ // of returning) is released here, in its own catch, rather than left for
148
+ // a confirmed-dead reap to eventually find.
149
+ const hold = await holdStagedGeneration({ root, generationRoot: target });
139
150
  try {
140
- const directories = new Set([staging]);
141
- for (const [name, file] of files) {
142
- const destination = path.join(staging, name);
143
- await mkdir(path.dirname(destination), { recursive: true });
144
- for (let parent = path.dirname(destination); parent !== staging; parent = path.dirname(parent)) directories.add(parent);
145
- const handle = await open(destination, "wx", file.mode);
151
+ if (await existingMatches(target, files)) return { ...result, hold };
152
+ const staging = await mkdtemp(path.join(generations, ".staging-"));
153
+ try {
154
+ await attachStagingTemp(hold, staging);
155
+ const directories = new Set([staging]);
156
+ for (const [name, file] of files) {
157
+ const destination = path.join(staging, name);
158
+ await mkdir(path.dirname(destination), { recursive: true });
159
+ for (let parent = path.dirname(destination); parent !== staging; parent = path.dirname(parent)) directories.add(parent);
160
+ const handle = await open(destination, "wx", file.mode);
161
+ try {
162
+ await handle.writeFile(file.bytes);
163
+ await handle.chmod(file.mode);
164
+ await handle.sync();
165
+ } finally { await handle.close(); }
166
+ }
167
+ for (const directory of [...directories].sort((a, b) => b.length - a.length)) {
168
+ const handle = await open(directory, "r");
169
+ try { await handle.sync(); } finally { await handle.close(); }
170
+ }
146
171
  try {
147
- await handle.writeFile(file.bytes);
148
- await handle.chmod(file.mode);
149
- await handle.sync();
150
- } finally { await handle.close(); }
151
- }
152
- for (const directory of [...directories].sort((a, b) => b.length - a.length)) {
153
- const handle = await open(directory, "r");
172
+ await rename(staging, target);
173
+ } catch (error) {
174
+ if (!["EEXIST", "ENOTEMPTY"].includes(error.code) || !await existingMatches(target, files)) throw error;
175
+ }
176
+ const handle = await open(generations, "r");
154
177
  try { await handle.sync(); } finally { await handle.close(); }
178
+ return { ...result, hold };
179
+ } finally {
180
+ await rm(staging, { recursive: true, force: true });
155
181
  }
156
- try {
157
- await rename(staging, target);
158
- } catch (error) {
159
- if (!["EEXIST", "ENOTEMPTY"].includes(error.code) || !await existingMatches(target, files)) throw error;
160
- }
161
- const handle = await open(generations, "r");
162
- try { await handle.sync(); } finally { await handle.close(); }
163
- return result;
164
- } finally {
165
- await rm(staging, { recursive: true, force: true });
182
+ } catch (error) {
183
+ await releaseStagingHold(hold);
184
+ throw error;
166
185
  }
167
186
  }
@@ -3,12 +3,15 @@ import { mkdtemp, rm } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import path from "node:path";
5
5
  import { promisify } from "node:util";
6
+ import { declaredStoreVersion } from "./activation.mjs";
6
7
  import { validateManagedLocation } from "./location.mjs";
7
8
  import { stageOwnGeneration } from "./generation.mjs";
8
9
  import { writeLaunchers } from "./launchers.mjs";
9
10
  import { canonicalManagerRoot, readControl, writeControl } from "./state.mjs";
10
11
  import { scheduleWorker } from "./schedule.mjs";
12
+ import { releaseStagingHold } from "./staging.mjs";
11
13
  import { withManagerLock } from "./mutex.mjs";
14
+ import { retireManagedHolds } from "./retire.mjs";
12
15
 
13
16
  const exec = promisify(execFile);
14
17
  export async function verifyGeneration(runtime, { env = process.env } = {}) {
@@ -27,35 +30,44 @@ export async function verifyGeneration(runtime, { env = process.env } = {}) {
27
30
  export async function installManaged({ packageRoot, managerRoot, dataHome, home, targets, env, apply, cwd }) {
28
31
  const root = await validateManagedLocation({ managerRoot, dataHome, home, cwd, env });
29
32
  const candidate = await stageOwnGeneration({ packageRoot, managerRoot: root });
30
- await verifyGeneration(candidate, { env });
31
- return withManagerLock(root, async () => {
32
- const previous = await readControl(root);
33
- if (previous !== null && previous.home !== home) {
34
- throw new Error("this ACC data home manages another client home; use a separate ACC_DATA_HOME");
35
- }
36
- if (previous !== null && previous.active.root !== candidate.root) {
37
- throw new Error("install from the active managed runtime; use acc update to switch versions");
38
- }
39
- const runtime = { version: candidate.version, root: candidate.root };
40
- const autoPreference = previous?.autoPreference ?? previous?.auto ?? true;
41
- // Full removal pauses workers without revoking the user's choice. Keep a
42
- // partial-removal pause until explicit opt-in; this install may omit its failed target.
43
- const auto = previous?.targets.length ? previous.auto : autoPreference;
44
- const control = { schemaVersion: 1, active: runtime, pending: runtime, phase: "activating",
45
- auto, autoPreference, pin: previous?.pin ?? null,
46
- checkedAt: previous?.checkedAt ?? null, home,
47
- targets: [...new Set([...(previous?.targets ?? []), ...targets])], notice: null };
48
- await writeControl(root, control);
49
- const paths = await writeLaunchers(root, runtime.root);
50
- const result = await apply({ ...paths, preserveVersions: true });
51
- if (result.failed.length === 0) {
52
- const ready = await writeControl(root, { ...control, phase: "ready", pending: null });
53
- await scheduleWorker(root, ready, { env });
54
- } else {
55
- await writeControl(root, { ...control, notice: "Integration refresh is incomplete; run acc update to recover." });
56
- }
57
- return result;
58
- });
33
+ // The hold candidate.hold carries survives verification and publication;
34
+ // release it on every exit from this attempt, once the generation is
35
+ // either published (independently protected by the control pointer this
36
+ // writes below) or abandoned (any throw between here and there).
37
+ try {
38
+ await verifyGeneration(candidate, { env });
39
+ return await withManagerLock(root, async () => {
40
+ const previous = await readControl(root);
41
+ if (previous !== null && previous.home !== home) {
42
+ throw new Error("this ACC data home manages another client home; use a separate ACC_DATA_HOME");
43
+ }
44
+ if (previous !== null && previous.active.root !== candidate.root) {
45
+ throw new Error("install from the active managed runtime; use acc update to switch versions");
46
+ }
47
+ const runtime = { version: candidate.version, root: candidate.root,
48
+ storeVersion: await declaredStoreVersion(candidate.root) };
49
+ const autoPreference = previous?.autoPreference ?? previous?.auto ?? true;
50
+ // Full removal pauses workers without revoking the user's choice. Keep a
51
+ // partial-removal pause until explicit opt-in; this install may omit its failed target.
52
+ const auto = previous?.targets.length ? previous.auto : autoPreference;
53
+ const control = { schemaVersion: 1, active: runtime, pending: runtime, phase: "activating",
54
+ auto, autoPreference, pin: previous?.pin ?? null,
55
+ checkedAt: previous?.checkedAt ?? null, home,
56
+ targets: [...new Set([...(previous?.targets ?? []), ...targets])], notice: null };
57
+ await writeControl(root, control);
58
+ const paths = await writeLaunchers(root, runtime.root);
59
+ const result = await apply({ ...paths, preserveVersions: true });
60
+ if (result.failed.length === 0) {
61
+ const ready = await writeControl(root, { ...control, phase: "ready", pending: null });
62
+ await scheduleWorker(root, ready, { env });
63
+ } else {
64
+ await writeControl(root, { ...control, notice: "Integration refresh is incomplete; run acc update to recover." });
65
+ }
66
+ return result;
67
+ });
68
+ } finally {
69
+ await releaseStagingHold(candidate.hold);
70
+ }
59
71
  }
60
72
 
61
73
  /** Removal updates enrollment under the same fence, so an updater cannot reinstall it. */
@@ -69,6 +81,11 @@ export async function uninstallManaged({ managerRoot, apply }) {
69
81
  const removed = new Set(result.operations.filter(operation => operation.applied)
70
82
  .map(operation => operation.adapterId));
71
83
  const targets = control.targets.filter(id => !removed.has(id));
84
+ // Holds this install published must stop blocking a later activation. The
85
+ // client processes keep running; only the records ACC owns are retired.
86
+ if (targets.length === 0) {
87
+ await retireManagedHolds({ root, workspaces: path.join(path.dirname(root), "workspaces") });
88
+ }
72
89
  await writeControl(root, { ...control, targets,
73
90
  autoPreference: control.autoPreference ?? control.auto,
74
91
  auto: targets.length > 0 && result.failed.length === 0 && control.auto,
@@ -50,7 +50,7 @@ export function maintenanceNotice(job) {
50
50
  : job.waiting?.reason === "other_processes" ? `${job.waiting.processes} other or unidentified process(es) to exit`
51
51
  : job.waiting?.reason === "caller_exit" ? "the requesting command to exit"
52
52
  : job.waiting?.reason === "update_worker" ? "another ACC updater"
53
- : "idle clients and other ACC processes to exit";
53
+ : "idle client work and any ACC process still holding this update";
54
54
  return `ACC ${version} update maintenance is scheduled; waiting for ${waiting}. Open clients will disconnect during restart. Check acc doctor for progress.`;
55
55
  }
56
56
  return `ACC ${version} update maintenance: ${job.status}. Check acc doctor for the result.`;
@@ -71,7 +71,15 @@ export async function runMaintenance(root, { jobId, env = process.env, adapters
71
71
  result = await withManagerLock(`${root}/worker`, async () => {
72
72
  const beforeActivation = async current => {
73
73
  if (maintenanceRecipe(current) !== job.recipe) throw refused("update_state_changed");
74
- const blockers = await listActivationBlockers(root, { pidIsAlive });
74
+ // Same fallback as maintenance.mjs's own target: judge other
75
+ // holds against the pending generation's contract when one is
76
+ // being activated, otherwise against the generation already
77
+ // running, since there is no incoming generation to compare to.
78
+ // Pick the whole generation record, not a per-field fallback, so
79
+ // a pending generation declaring no contract of its own is
80
+ // judged as "none" rather than silently borrowing active's.
81
+ const blockers = await listActivationBlockers(root, { pidIsAlive,
82
+ incomingStoreVersion: (current.pending ?? current.active).storeVersion });
75
83
  const others = blockers.filter(b => b.pid !== expected.pid || b.kinds.some(kind => kind !== "native"));
76
84
  if (others.length) throw deferred({ reason: "other_processes", processes: others.length });
77
85
  const snapshot = await adapter.inspectMaintenance(context);
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import path from "node:path";
4
+ import { evaluateVersionContract } from "@agents-can-communicate/adapter-sdk";
4
5
  import { ALL_ADAPTERS } from "../install-command.mjs";
5
6
  import { listActivationBlockers } from "./activation.mjs";
6
7
  import { confirmedDead, withManagerLock } from "./mutex.mjs";
@@ -8,17 +9,64 @@ import { readControl, readManagedJson } from "./state.mjs";
8
9
  import { MAINTENANCE_ACTIVE, maintenanceNotice, maintenanceRecipe, maintenanceReport,
9
10
  readMaintenance, writeMaintenance } from "./maintenance-state.mjs";
10
11
 
12
+ const HOST_PLATFORM = `${process.platform}-${process.arch}`;
13
+
11
14
  export function maintenanceContext(control, env) {
12
- return { home: control.home, env, platform: `${process.platform}-${process.arch}` };
15
+ return { home: control.home, env, platform: HOST_PLATFORM };
13
16
  }
14
17
 
18
+ /** The fifth site that used to compare a service's serving version with the
19
+ * CLI's, and the only one that acts on the answer by interrupting the user.
20
+ * The other four now judge the serving version against the adapter's captured
21
+ * native-delivery contract, and this one has to say the same thing: a Codex
22
+ * daemon still running the previous build while its CLI has updated is the
23
+ * ordinary state this release stops treating as a fault, and telling that user
24
+ * a restart is required - disconnecting every open client - is the complaint
25
+ * the work began from.
26
+ *
27
+ * A restart is still offered whenever the serving version does *not* satisfy
28
+ * that contract: below the captured minimum, on the denylist, a prerelease, or
29
+ * unreadable. Only a version the contract actually accepts stops being a
30
+ * reason on its own - and the native-binding blocker it is OR-ed with is
31
+ * untouched, so a genuinely stale or unbound service is still selected.
32
+ *
33
+ * The contract answers two different questions with one field, and reading
34
+ * every non-null code as a refusal conflated them. "platform_not_captured" and
35
+ * "native_delivery_unsupported" are not verdicts on any version: the first is
36
+ * a complete, valid declaration that records nothing for the platform this
37
+ * process runs on, and the second is an adapter that declares no native
38
+ * delivery at all. Every shipped adapter captures darwin-arm64 alone, so on
39
+ * Linux and on an Intel Mac the first of those is the ordinary answer - and it
40
+ * made every ready daemon a restart candidate, including one already serving
41
+ * exactly the version its CLI is running. That is the complaint this work
42
+ * began from, reproduced on every machine but one. With no opinion to act on,
43
+ * the decision falls back to the comparison that governed before this branch:
44
+ * the served version against the CLI's. */
45
+ const UNCAPTURED = new Set(["platform_not_captured", "native_delivery_unsupported"]);
46
+
47
+ const servingVersionIsContrary = (adapter, snapshot) => {
48
+ const { reasonCode } = evaluateVersionContract(adapter,
49
+ { clientVersion: snapshot.serverVersion, platform: HOST_PLATFORM });
50
+ if (reasonCode === null) return false;
51
+ if (!UNCAPTURED.has(reasonCode)) return true;
52
+ return snapshot.serverVersion !== snapshot.cliVersion;
53
+ };
54
+
15
55
  export async function inspectMaintenanceServices({ control, env = process.env, root, adapters = ALL_ADAPTERS() }) {
16
- const blockers = await listActivationBlockers(root, { ignorePid: process.pid });
56
+ // Maintenance targets the pending generation when one exists (its contract
57
+ // is what activation will judge holds against), otherwise the active one:
58
+ // there is no incoming generation, only a stale daemon to bring in line
59
+ // with what is already running. Same fallback as this file's own `target`
60
+ // below: pick the whole generation record, not a per-field fallback, so a
61
+ // pending generation that declares no contract of its own is judged as
62
+ // "none" and not silently swapped for the active generation's.
63
+ const blockers = await listActivationBlockers(root, { ignorePid: process.pid,
64
+ incomingStoreVersion: (control.pending ?? control.active).storeVersion });
17
65
  const services = [];
18
66
  for (const adapter of adapters.filter(a => control.targets.includes(a.id) && a.inspectMaintenance)) {
19
67
  const snapshot = await adapter.inspectMaintenance(maintenanceContext(control, env));
20
68
  if (!snapshot || !["ready", "busy"].includes(snapshot.state)) continue;
21
- if (snapshot.serverVersion !== snapshot.cliVersion
69
+ if (servingVersionIsContrary(adapter, snapshot)
22
70
  || blockers.some(b => b.pid === snapshot.pid && b.kinds.includes("native"))) {
23
71
  services.push({ adapterId: adapter.id, snapshot });
24
72
  }
@@ -75,7 +123,7 @@ export async function requestMaintenance({ root, control, options, runtime, serv
75
123
  const approved = await runtime.confirm?.(`Restart ${names} to finish updating ACC? Open clients will disconnect; ACC will wait for idle turns, refresh integrations, restart the service and verify it. A turn started during the final restart check can be interrupted.`,
76
124
  { input: runtime.input, output: runtime.output });
77
125
  if (approved !== true) return { data: { reason: "maintenance_declined" },
78
- text: "Client restart declined; the update remains pending until running clients exit." };
126
+ text: "Client restart declined; the update remains pending until the holds blocking it clear." };
79
127
  }
80
128
  return withManagerLock(root, async () => {
81
129
  const current = await readControl(root), previous = await readMaintenance(root);
@@ -1,11 +1,13 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import path from "node:path";
3
3
  import { promisify } from "node:util";
4
+ import { declaredStoreVersion } from "./activation.mjs";
4
5
  import { newerVersion } from "./download.mjs";
5
6
  import { stageOwnGeneration } from "./generation.mjs";
6
7
  import { verifyGeneration } from "./install.mjs";
7
8
  import { confirmedDead, withManagerLock } from "./mutex.mjs";
8
9
  import { readControl, readManagedJson, writeControl } from "./state.mjs";
10
+ import { releaseStagingHold } from "./staging.mjs";
9
11
 
10
12
  /** A newly installed global CLI can supersede an older pending release locally.
11
13
  * No workspace code or integration is admitted until normal activation passes.
@@ -18,15 +20,22 @@ export async function stageNewerManagementRuntime(root, { packageRoot, env }) {
18
20
  || manifest.accManagedUpdateProtocol !== 2 || before.pin && before.pin !== manifest.version
19
21
  || !newerVersion(manifest.version, before.pending?.version ?? before.active.version)) return false;
20
22
  const candidate = await stageOwnGeneration({ packageRoot, managerRoot: root });
21
- await verifyGeneration(candidate, { env });
22
- return withManagerLock(root, async () => {
23
- const current = await readControl(root);
24
- if (current.phase !== "ready" || current.pin !== before.pin
25
- || JSON.stringify([current.active, current.pending]) !== JSON.stringify([before.active, before.pending])) return false;
26
- await writeControl(root, { ...current, pending: { root: candidate.root, version: candidate.version },
27
- notice: `ACC ${candidate.version} is verified and ready to activate.` });
28
- return true;
29
- });
23
+ // See installManaged for why this releases only after the publish attempt
24
+ // - success or not - has fully resolved.
25
+ try {
26
+ await verifyGeneration(candidate, { env });
27
+ return await withManagerLock(root, async () => {
28
+ const current = await readControl(root);
29
+ if (current.phase !== "ready" || current.pin !== before.pin
30
+ || JSON.stringify([current.active, current.pending]) !== JSON.stringify([before.active, before.pending])) return false;
31
+ await writeControl(root, { ...current, pending: { root: candidate.root,
32
+ version: candidate.version, storeVersion: await declaredStoreVersion(candidate.root) },
33
+ notice: `ACC ${candidate.version} is verified and ready to activate.` });
34
+ return true;
35
+ });
36
+ } finally {
37
+ await releaseStagingHold(candidate.hold);
38
+ }
30
39
  }
31
40
 
32
41
  async function inspectWorker(pid) {
@@ -0,0 +1,125 @@
1
+ import { createHash } from "node:crypto";
2
+ import { access, readdir, rm } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { STORE_VERSION } from "@agents-can-communicate/storage-filesystem";
6
+
7
+ import { confirmedDead, defaultPidIsAlive } from "./mutex.mjs";
8
+ import { canonicalManagerRoot, managedDirectory, readManagedJson, syncDirectory, validateRuntime,
9
+ writeManagedJson } from "./state.mjs";
10
+
11
+ const SCHEMA_VERSION = 1;
12
+
13
+ // Same hashing rule as session bindings: a foreign harness id never selects
14
+ // which file is written.
15
+ const fileFor = (root, harnessSessionId) => path.join(root, "pins",
16
+ `${createHash("sha256").update(String(harnessSessionId)).digest("hex").slice(0, 32)}.json`);
17
+
18
+ // Where a generation's own hook lives, given its root. Computed once here and
19
+ // reused by every caller that needs it - a hook resolving a pin and the hook
20
+ // actually importing it - so the path checked and the path imported can never
21
+ // drift apart.
22
+ export const hookEntrypointFor = root => path.join(root, "bin", "entrypoints", "acc-hook.mjs");
23
+
24
+ // Every function below takes whatever `root` its caller happens to have -
25
+ // `entry.mjs` already canonicalises it before a hook ever sees it, but the
26
+ // hook-runner writes a pin from a raw data-home path it built itself. If only
27
+ // one side resolved symlinks, a symlinked data home would make the writer and
28
+ // the reader agree on the file's *name* but not its *location*: delegation
29
+ // would silently never fire, with no error anywhere. Canonicalising here,
30
+ // once, on every entry point - matching every other managed-runtime module -
31
+ // is what makes "the root the writer used" and "the root the reader used"
32
+ // the same derivation instead of two that merely happen to agree today.
33
+ export async function writePin({ root, harnessSessionId, runtimeRoot, version, storeVersion,
34
+ clientPid }) {
35
+ root = await canonicalManagerRoot(root);
36
+ await managedDirectory(path.join(root, "pins"), { create: true });
37
+ await writeManagedJson(fileFor(root, harnessSessionId), { schemaVersion: SCHEMA_VERSION,
38
+ harnessSessionId, runtimeRoot, version, storeVersion: storeVersion ?? null,
39
+ clientPid: clientPid ?? null, createdAt: new Date().toISOString() });
40
+ }
41
+
42
+ export async function readPin({ root, harnessSessionId }) {
43
+ root = await canonicalManagerRoot(root);
44
+ // Mirrors reapPins: an internal symlink swapped in for the pins directory
45
+ // must not be followed into silently, whether writing or reading.
46
+ if (!await managedDirectory(path.join(root, "pins"))) return null;
47
+ const record = await readManagedJson(fileFor(root, harnessSessionId)).catch(() => null);
48
+ if (!record || record.schemaVersion !== SCHEMA_VERSION
49
+ || typeof record.runtimeRoot !== "string" || record.runtimeRoot === "") return null;
50
+ return record;
51
+ }
52
+
53
+ export async function clearPin({ root, harnessSessionId }) {
54
+ root = await canonicalManagerRoot(root);
55
+ await rm(fileFor(root, harnessSessionId), { force: true });
56
+ }
57
+
58
+ /** Returns the pinned generation's root when a hook should delegate to it,
59
+ * and null when it should stay on the active generation. Resolution never
60
+ * throws: a hook that cannot resolve a pin must still let the client
61
+ * proceed, so every reason to say no - no pin, an already-active pin, a
62
+ * pinned generation outside the manager's own `generations` directory, one
63
+ * missing its own hook entrypoint, a pin declaring a store contract this
64
+ * generation does not speak, a store that will not read - collapses to the
65
+ * same null.
66
+ *
67
+ * Containment reuses `validateRuntime`, the same rule a control record's own
68
+ * generation pointer is held to, rather than trusting a pin's `runtimeRoot`
69
+ * on its word: a pin can be written by an unmanaged hook run sharing a data
70
+ * home (a development checkout is its own nearest `accStoreVersion`
71
+ * manifest), and importing whatever such a pin names would run this
72
+ * process's code from outside the runtime it was admitted into.
73
+ *
74
+ * The contract gate is the one check that is not about the pin being
75
+ * resolvable. A pin is deliberately not an activation hold, so a harness that
76
+ * lives only in hooks - no native binding, no long-lived channel process -
77
+ * holds nothing between turns and cannot keep an activation waiting. When
78
+ * that activation moves to a generation declaring a different
79
+ * `STORE_VERSION`, the pin survives it, and delegating to the generation it
80
+ * names would run code whose contract the store refuses by strict equality:
81
+ * the session would throw `unknown store version` on every hook and fall open
82
+ * for the rest of its life, silently uncoordinated, while its pin held the
83
+ * dead generation against reclamation. `STORE_VERSION` here is this
84
+ * generation's own declared contract - this module is loaded from the active
85
+ * generation, and its root manifest's `accStoreVersion` is asserted equal to
86
+ * this constant - so comparing against it compares the pin with the
87
+ * generation that would otherwise serve. Anything but proven equality (a
88
+ * differing contract, or a pin that declares none) falls back to the active
89
+ * generation, which is this function's answer to every other pin it cannot
90
+ * honour. That keeps the design's stated limit intact: a `STORE_VERSION`
91
+ * change still requires every live process to exit. */
92
+ export async function resolvePinnedGeneration({ root, harnessSessionId, active }) {
93
+ try {
94
+ const canonicalRoot = await canonicalManagerRoot(root);
95
+ const pin = await readPin({ root: canonicalRoot, harnessSessionId });
96
+ if (pin === null) return null;
97
+ const { root: resolved, storeVersion } = await validateRuntime(canonicalRoot,
98
+ { version: pin.version, root: pin.runtimeRoot, storeVersion: pin.storeVersion });
99
+ if (storeVersion !== STORE_VERSION) return null;
100
+ const canonicalActive = typeof active === "string" ? await canonicalManagerRoot(active) : active;
101
+ if (resolved === canonicalActive) return null;
102
+ await access(hookEntrypointFor(resolved));
103
+ return resolved;
104
+ } catch { return null; }
105
+ }
106
+
107
+ /** A client that exits without SessionEnd leaves its pin behind. Admission
108
+ * already reaps dead leases; pins follow the same confirmed-death rule. */
109
+ export async function reapPins({ root, pidIsAlive = defaultPidIsAlive } = {}) {
110
+ root = await canonicalManagerRoot(root);
111
+ const directory = path.join(root, "pins");
112
+ if (!await managedDirectory(directory)) return;
113
+ let removed = false;
114
+ for (const name of await readdir(directory)) {
115
+ if (!name.endsWith(".json")) continue;
116
+ const file = path.join(directory, name);
117
+ const record = await readManagedJson(file).catch(() => null);
118
+ const pid = Number.isSafeInteger(record?.clientPid) ? record.clientPid : null;
119
+ if (pid !== null && await confirmedDead(pid, pidIsAlive)) {
120
+ await rm(file, { force: true });
121
+ removed = true;
122
+ }
123
+ }
124
+ if (removed) await syncDirectory(directory);
125
+ }
@@ -0,0 +1,56 @@
1
+ import { readdir, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { managedDirectory, readManagedJson, syncDirectory } from "./state.mjs";
5
+
6
+ // A relative runtimeRoot resolves against the caller's cwd, not this manager's
7
+ // own root, so it can never be owned regardless of where acc uninstall runs
8
+ // from. The generations directory itself is a container, not a generation, so
9
+ // an exact match (empty relative) is foreign too. On Windows, path.relative
10
+ // returns an absolute path when the two roots sit on different drives, which
11
+ // is foreign by construction. This matches every case validateRuntime
12
+ // (state.mjs) applies to a control pointer. pathImpl is injectable only so a
13
+ // test can pin the cross-drive case through path.win32 without depending on
14
+ // the host OS; production code always uses the real, platform-native path.
15
+ export function isOwnedRuntimeRoot(generations, runtimeRoot, pathImpl = path) {
16
+ if (typeof runtimeRoot !== "string" || !pathImpl.isAbsolute(runtimeRoot)) return false;
17
+ const relative = pathImpl.relative(generations, runtimeRoot);
18
+ return relative !== "" && relative !== ".." && !relative.startsWith(`..${pathImpl.sep}`)
19
+ && !pathImpl.isAbsolute(relative);
20
+ }
21
+
22
+ /** Uninstall owns what it published. A binding naming a generation under this
23
+ * manager is ours; one naming a foreign root, or none at all, is not, and a
24
+ * client process is never signalled or terminated to clear a record. */
25
+ export async function retireManagedHolds({ root, workspaces }) {
26
+ const generations = path.join(root, "generations");
27
+ const counts = { bindings: 0, pins: 0 };
28
+ if (await managedDirectory(workspaces)) {
29
+ for (const entry of await readdir(workspaces, { withFileTypes: true })) {
30
+ if (!entry.isDirectory()) continue;
31
+ const directory = path.join(workspaces, entry.name, "bindings");
32
+ if (!await managedDirectory(directory)) continue;
33
+ let removed = false;
34
+ for (const name of await readdir(directory)) {
35
+ if (!name.endsWith(".json")) continue;
36
+ const file = path.join(directory, name);
37
+ const record = await readManagedJson(file).catch(() => null);
38
+ if (!isOwnedRuntimeRoot(generations, record?.runtimeRoot)) continue;
39
+ await rm(file, { force: true });
40
+ counts.bindings += 1;
41
+ removed = true;
42
+ }
43
+ if (removed) await syncDirectory(directory);
44
+ }
45
+ }
46
+ const pins = path.join(root, "pins");
47
+ if (await managedDirectory(pins)) {
48
+ for (const name of await readdir(pins)) {
49
+ if (!name.endsWith(".json")) continue;
50
+ await rm(path.join(pins, name), { force: true });
51
+ counts.pins += 1;
52
+ }
53
+ await syncDirectory(pins);
54
+ }
55
+ return counts;
56
+ }
@@ -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
+ }