agents-can-communicate 0.4.3 → 0.4.4

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 (51) hide show
  1. package/README.md +5 -3
  2. package/bin/acc-maintenance-worker.mjs +6 -0
  3. package/docs/ARCHITECTURE.md +12 -5
  4. package/docs/CLI.md +13 -6
  5. package/docs/CONCEPTS.md +2 -1
  6. package/docs/CONFIGURATION.md +2 -1
  7. package/docs/GETTING_STARTED.md +4 -2
  8. package/docs/HOW_IT_WORKS.md +2 -1
  9. package/docs/RELEASING.md +1 -1
  10. package/docs/SECURITY_MODEL.md +12 -5
  11. package/docs/TROUBLESHOOTING.md +5 -2
  12. package/docs/UPGRADING.md +54 -13
  13. package/docs/WHY_ACC.md +1 -1
  14. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  15. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  16. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +6 -2
  17. package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-host.mjs +120 -0
  18. package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-threads.mjs +49 -0
  19. package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance.mjs +108 -0
  20. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  21. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  22. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  23. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  24. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  25. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  26. package/node_modules/@agents-can-communicate/cli/src/args.mjs +1 -1
  27. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +3 -4
  28. package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -0
  29. package/node_modules/@agents-can-communicate/cli/src/main.mjs +5 -3
  30. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +45 -7
  31. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/command.mjs +15 -0
  32. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/diagnostics.mjs +35 -0
  33. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +40 -8
  34. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/legacy-maintenance.mjs +47 -0
  35. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance-state.mjs +57 -0
  36. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance-worker.mjs +110 -0
  37. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance.mjs +92 -0
  38. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/management-recovery.mjs +71 -0
  39. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/refresh.mjs +6 -7
  40. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +4 -2
  41. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +16 -4
  42. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  43. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  44. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  45. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  46. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +2 -2
  47. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +10 -3
  48. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  49. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  50. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  51. package/package.json +2 -1
@@ -0,0 +1,110 @@
1
+ import { ALL_ADAPTERS } from "../install-command.mjs";
2
+ import { activatePending, listActivationBlockers } from "./activation.mjs";
3
+ import { confirmedDead, defaultPidIsAlive, withManagerLock } from "./mutex.mjs";
4
+ import { readControl } from "./state.mjs";
5
+ import { maintenanceContext } from "./maintenance.mjs";
6
+ import { MAINTENANCE_ACTIVE, maintenanceRecipe, readMaintenance, writeMaintenance } from "./maintenance-state.mjs";
7
+
8
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
9
+ const deferred = waiting => Object.assign(new Error("maintenance waiting"), { code: "MAINTENANCE_WAIT", waiting });
10
+ const refused = reasonCode => Object.assign(new Error("maintenance refused"), { reasonCode });
11
+
12
+ /** No runtime lease: this worker must outlive the client that requested it.
13
+ * Its own mutex serializes checkpoints; admission remains separately fenced.
14
+ */
15
+ export async function runMaintenance(root, { jobId, env = process.env, adapters = ALL_ADAPTERS(),
16
+ pidIsAlive = defaultPidIsAlive, pause = sleep, now = Date.now, activate = activatePending } = {}) {
17
+ return withManagerLock(`${root}/maintenance-worker`, async () => {
18
+ let job;
19
+ // Publication owns admission until spawn's PID is durable. Wait for it
20
+ // before writing checkpoints, including when recovering an older job.
21
+ await withManagerLock(root, async () => {
22
+ job = await readMaintenance(root);
23
+ if (job && (!jobId || job.id === jobId) && MAINTENANCE_ACTIVE.includes(job.status)) {
24
+ job = await writeMaintenance(root, { ...job, workerPid: process.pid });
25
+ }
26
+ });
27
+ if (!job || jobId && job.id !== jobId || !MAINTENANCE_ACTIVE.includes(job.status)) return job;
28
+ const save = async changes => { job = await writeMaintenance(root, { ...job, ...changes }); return job; };
29
+ const waitFor = async waiting => {
30
+ if (JSON.stringify(job.waiting) !== JSON.stringify(waiting)) await save({ waiting });
31
+ await pause(1000);
32
+ };
33
+ const adapter = adapters.find(a => a.id === job.services[0].adapterId);
34
+ if (!adapter?.stopForMaintenance || !adapter.startAfterMaintenance) return save({ status: "failed", reasonCode: "maintenance_adapter_unavailable" });
35
+ const expected = job.services[0].snapshot;
36
+ let control = await readControl(root);
37
+ const context = maintenanceContext(control, env);
38
+
39
+ async function restore(result, reasonCode = null) {
40
+ await save({ status: "restarting", reasonCode });
41
+ let restarted;
42
+ try { restarted = await adapter.startAfterMaintenance({ context, expected }); }
43
+ catch { restarted = { ok: false, reasonCode: "service_start_failed" }; }
44
+ if (!restarted?.ok) return save({ status: "recovery", reasonCode: restarted?.reasonCode ?? "service_start_failed" });
45
+ return save({ status: result?.activated ? "completed" : "failed",
46
+ reasonCode: result?.activated ? null : reasonCode ?? result?.reason ?? "update_incomplete" });
47
+ }
48
+
49
+ // A crash after the stop checkpoint can mean either side of the command.
50
+ // Never repeat it. Forward repair still obeys native/ACC lifetime fences,
51
+ // then the idempotent adapter start restores the already-approved service.
52
+ if (job.attempted.length) {
53
+ let result;
54
+ try {
55
+ control = await readControl(root);
56
+ if (!control.pending && control.active.root === job.target.root && control.phase === "ready") result = { activated: true };
57
+ else if (maintenanceRecipe(control) === job.recipe) {
58
+ result = await withManagerLock(`${root}/worker`, () => activate(root, { env, pidIsAlive }));
59
+ }
60
+ } catch { /* Restoration still runs when forward repair is unavailable. */ }
61
+ return restore(result, result?.reason ?? "maintenance_interrupted");
62
+ }
63
+
64
+ for (;;) {
65
+ control = await readControl(root);
66
+ if (maintenanceRecipe(control) !== job.recipe) return save({ status: "cancelled", reasonCode: "update_state_changed" });
67
+ if (now() > job.deadline) return save({ status: "cancelled", reasonCode: "idle_wait_expired" });
68
+ if (!await confirmedDead(job.callerPid, pidIsAlive)) { await waitFor({ reason: "caller_exit" }); continue; }
69
+ let result;
70
+ try {
71
+ result = await withManagerLock(`${root}/worker`, async () => {
72
+ const beforeActivation = async current => {
73
+ if (maintenanceRecipe(current) !== job.recipe) throw refused("update_state_changed");
74
+ const blockers = await listActivationBlockers(root, { pidIsAlive });
75
+ const others = blockers.filter(b => b.pid !== expected.pid || b.kinds.some(kind => kind !== "native"));
76
+ if (others.length) throw deferred({ reason: "other_processes", processes: others.length });
77
+ const snapshot = await adapter.inspectMaintenance(context);
78
+ if (snapshot?.state === "busy") throw deferred({ reason: "service_busy",
79
+ busyThreads: snapshot.busyThreads, queuedRequests: snapshot.queuedRequests });
80
+ if (snapshot?.state !== "ready") throw refused("service_readiness_changed");
81
+ // Durable before the vendor command: recovery restores without
82
+ // assuming whether the previous worker reached the actual stop.
83
+ await save({ status: "stopping", attempted: [adapter.id], waiting: null });
84
+ const stopped = await adapter.stopForMaintenance({ context, expected });
85
+ if (stopped?.ok === false && stopped.stopAttempted === false) {
86
+ await save({ status: "waiting", attempted: [] });
87
+ if (stopped.reasonCode === "service_busy") throw deferred({ reason: "service_busy",
88
+ busyThreads: stopped.snapshot?.busyThreads, queuedRequests: stopped.snapshot?.queuedRequests });
89
+ }
90
+ if (!stopped?.ok) throw refused(stopped?.reasonCode ?? "service_stop_failed");
91
+ await save({ status: "refreshing" });
92
+ };
93
+ if (control.pending) return activate(root, { env, pidIsAlive, beforeActivation });
94
+ return withManagerLock(root, async () => {
95
+ await beforeActivation(await readControl(root));
96
+ return { activated: true, version: control.active.version };
97
+ });
98
+ }, { timeoutMs: 100 });
99
+ } catch (error) {
100
+ if (!job.attempted.length && (error.code === "MAINTENANCE_WAIT" || /manager lock held/.test(error.message))) {
101
+ await waitFor(error.waiting ?? { reason: "update_worker" }); continue;
102
+ }
103
+ if (job.attempted.length) return restore(null, error.reasonCode ?? "maintenance_failed");
104
+ return save({ status: "failed", reasonCode: error.reasonCode ?? "maintenance_failed" });
105
+ }
106
+ if (job.attempted.length) return restore(result);
107
+ return save({ status: "cancelled", reasonCode: result?.reason ?? "update_state_changed" });
108
+ }
109
+ }, { timeoutMs: 100, pidIsAlive });
110
+ }
@@ -0,0 +1,92 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import path from "node:path";
4
+ import { ALL_ADAPTERS } from "../install-command.mjs";
5
+ import { listActivationBlockers } from "./activation.mjs";
6
+ import { confirmedDead, withManagerLock } from "./mutex.mjs";
7
+ import { readControl, readManagedJson } from "./state.mjs";
8
+ import { MAINTENANCE_ACTIVE, maintenanceNotice, maintenanceRecipe, maintenanceReport,
9
+ readMaintenance, writeMaintenance } from "./maintenance-state.mjs";
10
+
11
+ export function maintenanceContext(control, env) {
12
+ return { home: control.home, env, platform: `${process.platform}-${process.arch}` };
13
+ }
14
+
15
+ export async function inspectMaintenanceServices({ control, env = process.env, root, adapters = ALL_ADAPTERS() }) {
16
+ const blockers = await listActivationBlockers(root, { ignorePid: process.pid });
17
+ const services = [];
18
+ for (const adapter of adapters.filter(a => control.targets.includes(a.id) && a.inspectMaintenance)) {
19
+ const snapshot = await adapter.inspectMaintenance(maintenanceContext(control, env));
20
+ if (!snapshot || !["ready", "busy"].includes(snapshot.state)) continue;
21
+ if (snapshot.serverVersion !== snapshot.cliVersion
22
+ || blockers.some(b => b.pid === snapshot.pid && b.kinds.includes("native"))) {
23
+ services.push({ adapterId: adapter.id, snapshot });
24
+ }
25
+ }
26
+ return services;
27
+ }
28
+
29
+ export async function spawnMaintenance({ root, job, packageRoot, env }) {
30
+ packageRoot = job.workerRoot ?? packageRoot;
31
+ const manifest = await readManagedJson(path.join(packageRoot, "package.json"));
32
+ if (manifest?.name !== "agents-can-communicate" || manifest.accManagedUpdateProtocol !== 2) {
33
+ throw new Error("maintenance worker implementation is unavailable");
34
+ }
35
+ const child = spawn(process.execPath, [path.join(packageRoot, "bin", "acc-maintenance-worker.mjs"), root, job.id],
36
+ { env, cwd: root, detached: true, stdio: "ignore" });
37
+ await new Promise((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); });
38
+ child.unref();
39
+ return child.pid;
40
+ }
41
+
42
+ const response = job => ({ data: { reason: "maintenance_pending", maintenance: maintenanceReport(job) }, text: maintenanceNotice(job) });
43
+
44
+ async function launch(root, job, runtime) {
45
+ if (job.workerPid !== null && !await confirmedDead(job.workerPid)) return job;
46
+ try {
47
+ // The child reads after publication; a separate lifetime lock prevents a
48
+ // second command from creating two workers or overwriting its checkpoint.
49
+ const workerPid = await (runtime.spawnMaintenance ?? spawnMaintenance)({ root, job,
50
+ packageRoot: runtime.packageRoot, env: runtime.env ?? process.env });
51
+ return writeMaintenance(root, { ...job, workerPid });
52
+ } catch {
53
+ return writeMaintenance(root, { ...job, status: job.attempted.length ? "recovery" : "failed",
54
+ reasonCode: "maintenance_worker_start_failed" });
55
+ }
56
+ }
57
+
58
+ /** Explicit command only. Background inspection never grants restart consent. */
59
+ export async function requestMaintenance({ root, control, options, runtime, services }) {
60
+ const existing = await readMaintenance(root);
61
+ if (existing && MAINTENANCE_ACTIVE.includes(existing.status)) {
62
+ return withManagerLock(root, async () => {
63
+ const latest = await readMaintenance(root);
64
+ if (!latest || !MAINTENANCE_ACTIVE.includes(latest.status)) return latest ? response(latest) : null;
65
+ return response(await launch(root, latest, runtime));
66
+ });
67
+ }
68
+ services ??= await inspectMaintenanceServices({ root, control, env: runtime.env, adapters: runtime.adapters });
69
+ if (services.length !== 1) return null; // More than one service needs a separate captured contract.
70
+ const names = services.map(s => s.snapshot.serviceId).join(", ");
71
+ if (!options.yes) {
72
+ if (options.json || runtime.isInteractive?.() !== true) return {
73
+ data: { reason: "confirmation_required", services: services.map(s => ({ adapterId: s.adapterId, serviceId: s.snapshot.serviceId })) },
74
+ text: `Finishing the update requires restarting ${names}, disconnecting open clients. Run acc update in a terminal to confirm, or acc update --yes to consent explicitly.` };
75
+ 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
+ { input: runtime.input, output: runtime.output });
77
+ if (approved !== true) return { data: { reason: "maintenance_declined" },
78
+ text: "Client restart declined; the update remains pending until running clients exit." };
79
+ }
80
+ return withManagerLock(root, async () => {
81
+ const current = await readControl(root), previous = await readMaintenance(root);
82
+ if (previous && MAINTENANCE_ACTIVE.includes(previous.status)) return response(previous);
83
+ if (maintenanceRecipe(current) !== maintenanceRecipe(control)) return {
84
+ data: { reason: "state_changed" }, text: "Update settings changed before confirmation; run acc update again." };
85
+ const job = await writeMaintenance(root, { schemaVersion: 1, id: randomUUID(), status: "waiting",
86
+ target: current.pending ?? current.active, recipe: maintenanceRecipe(current), services,
87
+ workerRoot: runtime.packageRoot,
88
+ approvedAt: new Date().toISOString(), deadline: Date.now() + 15 * 60_000,
89
+ callerPid: process.pid, workerPid: null, attempted: [], reasonCode: null });
90
+ return response(await launch(root, job, runtime));
91
+ });
92
+ }
@@ -0,0 +1,71 @@
1
+ import { execFile } from "node:child_process";
2
+ import path from "node:path";
3
+ import { promisify } from "node:util";
4
+ import { newerVersion } from "./download.mjs";
5
+ import { stageOwnGeneration } from "./generation.mjs";
6
+ import { verifyGeneration } from "./install.mjs";
7
+ import { confirmedDead, withManagerLock } from "./mutex.mjs";
8
+ import { readControl, readManagedJson, writeControl } from "./state.mjs";
9
+
10
+ /** A newly installed global CLI can supersede an older pending release locally.
11
+ * No workspace code or integration is admitted until normal activation passes.
12
+ */
13
+ export async function stageNewerManagementRuntime(root, { packageRoot, env }) {
14
+ if (!packageRoot) return false;
15
+ const before = await readControl(root);
16
+ const manifest = await readManagedJson(path.join(packageRoot, "package.json"));
17
+ if (before?.phase !== "ready" || manifest?.name !== "agents-can-communicate"
18
+ || manifest.accManagedUpdateProtocol !== 2 || before.pin && before.pin !== manifest.version
19
+ || !newerVersion(manifest.version, before.pending?.version ?? before.active.version)) return false;
20
+ 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
+ });
30
+ }
31
+
32
+ async function inspectWorker(pid) {
33
+ try {
34
+ const { stdout } = await promisify(execFile)("/bin/ps", ["-p", String(pid), "-o", "uid=,lstart=,command="],
35
+ { timeout: 2000, maxBuffer: 16384 });
36
+ const match = /^\s*(\d+)\s+(.{24})\s+(.+)\s*$/.exec(stdout);
37
+ return match ? { uid: Number(match[1]), start: match[2], command: match[3] } : null;
38
+ } catch { return null; }
39
+ }
40
+
41
+ /** 0.4.2/0.4.3 kept this ACC-owned worker lock while sleeping indefinitely.
42
+ * Retire only an exactly identified old helper, under the admission mutex in
43
+ * ready phase. Never interrupt integration writes, a client, or an unknown PID.
44
+ */
45
+ export async function retireLegacyUpdateWorker(root, { inspect = inspectWorker,
46
+ signal = pid => process.kill(pid, "SIGTERM"), pause = ms => new Promise(resolve => setTimeout(resolve, ms)) } = {}) {
47
+ return withManagerLock(root, async () => {
48
+ const control = await readControl(root);
49
+ if (control?.phase !== "ready") return false;
50
+ const manifest = await readManagedJson(path.join(control.active.root, "package.json"));
51
+ if (manifest?.accManagedUpdateProtocol === 2) return false;
52
+ const ownerFile = path.join(root, "worker", "manager.lock", "owner.json");
53
+ const owner = await readManagedJson(ownerFile);
54
+ if (!Number.isSafeInteger(owner?.pid) || owner.pid < 2 || owner.pid === process.pid
55
+ || typeof owner.token !== "string" || !Number.isFinite(Date.parse(owner.acquiredAt))) return false;
56
+ const observed = await inspect(owner.pid);
57
+ const suffix = ` ${path.join(control.active.root, "bin", "acc-update-worker.mjs")} ${root}`;
58
+ const executable = observed?.command.endsWith(suffix) ? observed.command.slice(0, -suffix.length) : null;
59
+ const elapsed = Date.parse(owner.acquiredAt) - Date.parse(observed?.start);
60
+ if (!executable || !path.isAbsolute(executable) || path.basename(executable) !== "node"
61
+ || observed.uid !== process.getuid?.() || !Number.isFinite(elapsed) || elapsed < 0 || elapsed > 60_000) return false;
62
+ if (JSON.stringify(await readManagedJson(ownerFile)) !== JSON.stringify(owner)
63
+ || JSON.stringify(await inspect(owner.pid)) !== JSON.stringify(observed)) return false;
64
+ signal(owner.pid);
65
+ for (let attempt = 0; attempt < 40; attempt++) {
66
+ if (await confirmedDead(owner.pid)) return true;
67
+ await pause(50);
68
+ }
69
+ return false; // Normal mutex reclamation still requires confirmed death.
70
+ });
71
+ }
@@ -3,9 +3,11 @@ import { applyPlan, detectInstallation, livePolicyOf, loadOwnership, planInstall
3
3
  from "@agents-can-communicate/installer";
4
4
  import { ALL_ADAPTERS, clientContext, probeTimeout } from "../install-command.mjs";
5
5
  import { stablePaths, writeLaunchers } from "./launchers.mjs";
6
+ import { bridgeLegacyMaintenance } from "./legacy-maintenance.mjs";
6
7
 
7
8
  /** Loaded from the candidate, so its own adapters supply its integration bytes. */
8
- export async function prepareRefresh({ control, root, env = process.env }) {
9
+ export async function prepareRefresh({ control, root, env = process.env, callerProtocol = 1 }) {
10
+ await bridgeLegacyMaintenance({ control, root, env, callerProtocol });
9
11
  const dataHome = path.dirname(path.dirname(root));
10
12
  const wanted = new Set(control.targets);
11
13
  const adapters = ALL_ADAPTERS().filter(adapter => wanted.has(adapter.id));
@@ -16,12 +18,9 @@ export async function prepareRefresh({ control, root, env = process.env }) {
16
18
  const detected = await detectInstallation({ adapters, context, probeTimeoutMs: probeTimeout(env) });
17
19
  const deliveryByAdapter = Object.fromEntries(adapters.map(adapter => [adapter.id,
18
20
  livePolicyOf(recorded.find(record => record.adapterId === adapter.id))]));
19
- // An unavailable probe cannot silently revoke an existing native opt-in.
20
- for (const detection of detected) {
21
- if (deliveryByAdapter[detection.adapterId] !== "off" && detection.nativeDelivery?.state !== "eligible") {
22
- throw new Error("native integration could not be verified; update remains pending");
23
- }
24
- }
21
+ // Refresh configuration even when the client's service is temporarily absent.
22
+ // The installer preserves consent and existing activation; readiness stays a
23
+ // separate, current probe result rather than an update prerequisite.
25
24
  const plan = planInstallation({ adapters, detected, context, recorded,
26
25
  accVersion: control.pending.version, requested: control.targets, deliveryByAdapter });
27
26
  if (plan.skipped.length || plan.operations.length !== wanted.size) {
@@ -8,8 +8,10 @@ import { checkDue, networkDisabled } from "./policy.mjs";
8
8
  export async function scheduleWorker(root, control, { env = process.env } = {}) {
9
9
  if (!control?.auto || networkDisabled(env) || !control.pending && !checkDue(control)) return false;
10
10
  try {
11
- const owner = await readManagedJson(path.join(root, "worker", "manager.lock", "owner.json"));
12
- if (Number.isSafeInteger(owner?.pid) && !await confirmedDead(owner.pid)) return false;
11
+ for (const directory of [path.join(root, "worker"), path.join(root, "worker", "poller")]) {
12
+ const owner = await readManagedJson(path.join(directory, "manager.lock", "owner.json"));
13
+ if (Number.isSafeInteger(owner?.pid) && !await confirmedDead(owner.pid)) return false;
14
+ }
13
15
  const child = spawn(process.execPath,
14
16
  [path.join(control.active.root, "bin", "acc-update-worker.mjs"), root], {
15
17
  env, detached: true, stdio: "ignore", cwd: root,
@@ -2,6 +2,8 @@ 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 { MAINTENANCE_ACTIVE, maintenanceNotice, maintenanceReport, readMaintenance } from "./maintenance-state.mjs";
6
+ import { requestMaintenance } from "./maintenance.mjs";
5
7
 
6
8
  import { checkDue, networkDisabled } from "./policy.mjs";
7
9
  export { CHECK_INTERVAL_MS, networkDisabled } from "./policy.mjs";
@@ -14,6 +16,14 @@ export async function performUpdate(root, { force = false, check = false, env =
14
16
  if (initial === null) throw new Error("ACC is not enrolled; run acc install first");
15
17
  const data = { running: initial.active.version, auto: initial.auto, pin: initial.pin,
16
18
  pending: initial.pending?.version ?? null, notice: initial.notice };
19
+ const maintenance = await readMaintenance(root);
20
+ if (MAINTENANCE_ACTIVE.includes(maintenance?.status)) {
21
+ if (check) return { ...data, checked: false, reason: "maintenance_pending",
22
+ maintenance: maintenanceReport(maintenance), notice: maintenanceNotice(maintenance) };
23
+ const response = await requestMaintenance({ root, control: initial, options: {},
24
+ runtime: { env, packageRoot: initial.pending?.root ?? initial.active.root, isInteractive: () => false } });
25
+ return { ...data, checked: false, ...response.data, notice: response.text };
26
+ }
17
27
  if (!force && !check && !initial.auto) return { ...data, checked: false, reason: "auto_off" };
18
28
  // Recovery is local; the hard no-network override must not strand a partial refresh.
19
29
  if (initial.pending && !check && (force || !networkDisabled(env))) {
@@ -42,16 +52,18 @@ export async function performUpdate(root, { force = false, check = false, env =
42
52
  return { ...checked, pending: generation.version, ...await activate(root, { env, ignorePid }) };
43
53
  }
44
54
 
45
- /** A single detached worker may wait cheaply for native clients to exit. */
55
+ /** One poller may wait; the update lock stays available between its passes. */
46
56
  export async function runWorker(root, { force = false, env = process.env, wait = false, ignorePid = null } = {}) {
47
- return withManagerLock(`${root}/worker`, async () => {
57
+ const poll = async () => {
48
58
  for (;;) {
49
- const result = await performUpdate(root, { force, env, ignorePid });
59
+ const result = await withManagerLock(`${root}/worker`,
60
+ () => performUpdate(root, { force, env, ignorePid }), { timeoutMs: 100 });
50
61
  if (!wait || result.reason !== "processes_active") return result;
51
62
  await new Promise(resolve => setTimeout(resolve, 60_000));
52
63
  if (!(await readControl(root))?.auto || networkDisabled(env)) return result;
53
64
  }
54
- }, { timeoutMs: 100 });
65
+ };
66
+ return wait ? withManagerLock(`${root}/worker/poller`, poll, { timeoutMs: 100 }) : poll();
55
67
  }
56
68
 
57
69
  export async function recordWorkerFailure(root, error) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/core",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
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.3",
3
+ "version": "0.4.4",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/hook-runner",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/installer",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -37,7 +37,7 @@ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = fa
37
37
  const installContext = { ...context,
38
38
  clientVersion: operation.clientVersion, platform: operation.platform,
39
39
  requestedLivePolicy: operation.livePolicy ?? "off",
40
- livePolicy: operation.effectiveLivePolicy ?? "off" };
40
+ livePolicy: operation.configuredLivePolicy ?? operation.effectiveLivePolicy ?? "off" };
41
41
  const outcome = await adapter.install(installContext);
42
42
  // A consented activation is applied after the adapter's own wiring, in
43
43
  // a fixed order; an explicit off takes a recorded one back first so the
@@ -50,7 +50,7 @@ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = fa
50
50
  notes.push(...describeTeardown(report));
51
51
  retainedMechanisms = report.retainedMechanisms;
52
52
  }
53
- let native = null;
53
+ let native = operation.retainedNativeActivation ?? null;
54
54
  let appendedRcBlock = false;
55
55
  if (operation.nativeActivation !== undefined) {
56
56
  const applied = await applyNativeActivation({ adapter,
@@ -108,6 +108,10 @@ export function planInstallation({ adapters, detected, context, action = "instal
108
108
  const liveDeliverySupported = native?.state === "eligible"
109
109
  && native.activationPlan?.eligible === true;
110
110
  const effectiveLivePolicy = liveDeliverySupported ? delivery : "off";
111
+ const previous = recordedById.get(entry.adapterId)?.nativeActivation ?? null;
112
+ const retainedActivation = action === "install" && delivery !== "off"
113
+ && !liveDeliverySupported && previous?.livePolicy === delivery ? previous : null;
114
+ const configuredLivePolicy = retainedActivation ? delivery : effectiveLivePolicy;
111
115
  const deliveryDiagnostic = action === "install" && delivery !== "off"
112
116
  && !liveDeliverySupported
113
117
  ? entry.deliveryDiagnostic ?? adapter.deliveryFallback?.diagnostic
@@ -117,20 +121,21 @@ export function planInstallation({ adapters, detected, context, action = "instal
117
121
  const deliverySummary = action === "install"
118
122
  ? describeInstallDelivery(entry, delivery, effectiveLivePolicy) : null;
119
123
  const installContext = { ...context, requestedLivePolicy: delivery,
120
- livePolicy: effectiveLivePolicy, clientVersion: entry.version, platform: entry.platform };
124
+ livePolicy: configuredLivePolicy, clientVersion: entry.version, platform: entry.platform };
121
125
  const setupNotes = action === "install" && delivery !== "off"
122
126
  ? [entry.outgoingDelivery?.setup, entry.nativeSetup].filter(note => typeof note === "string") : [];
123
127
  // A consented activation that this run keeps, activates, or takes back.
124
128
  // Only an explicit off or an uninstall removes one; an absent record never
125
129
  // creates one.
126
- const previous = recordedById.get(entry.adapterId)?.nativeActivation ?? null;
127
130
  const nativeActivation = action === "install" && effectiveLivePolicy !== "off"
128
131
  ? { livePolicy: effectiveLivePolicy, protocolContract: native.eligibility.protocolContract,
129
132
  shell: context?.shell ?? null, rcFile: rcFileFor(context?.home, context?.shell),
130
133
  shimDir: typeof context?.stateRoot === "string" ? shimDirFor(context.stateRoot) : null,
131
134
  mechanisms: native.activationPlan.mechanisms }
132
135
  : null;
133
- const retirements = planActivationRetirements({ previous, desired: nativeActivation });
136
+ // A failed readiness probe is not revocation. Keep existing guarded launch
137
+ // setup without applying service commands or claiming it is available.
138
+ const retirements = retainedActivation ? [] : planActivationRetirements({ previous, desired: nativeActivation });
134
139
  const deactivation = retirements.length > 0 ? { ...previous, mechanisms: retirements } : null;
135
140
  const artifacts = (record?.artifacts ?? adapter.planInstall(installContext))
136
141
  .map(artifact => ({ path: artifact.path, kind: artifact.kind ?? "file" }))
@@ -149,6 +154,7 @@ export function planInstallation({ adapters, detected, context, action = "instal
149
154
  alreadyInstalled: entry.installed === true,
150
155
  livePolicy: delivery,
151
156
  effectiveLivePolicy,
157
+ ...(retainedActivation ? { configuredLivePolicy, retainedNativeActivation: retainedActivation } : {}),
152
158
  ...(deliveryDiagnostic === null ? {} : { deliveryDiagnostic }),
153
159
  ...(deliverySummary === null ? {} : { deliverySummary }),
154
160
  ...(setupNotes.length ? { setupNotes } : {}),
@@ -161,6 +167,7 @@ export function planInstallation({ adapters, detected, context, action = "instal
161
167
  ...(entry.present ? [] : [`${adapter.displayName ?? adapter.id} is no longer on `
162
168
  + "this machine; removing what ACC recorded writing"]),
163
169
  ...(deliverySummary === null ? [] : [deliverySummary]),
170
+ ...(retainedActivation ? ["keep existing native delivery setup; current readiness is unverified"] : []),
164
171
  ...setupNotes,
165
172
  ...artifacts.filter(a => a.kind === "tree")
166
173
  .map(a => `${action === "install" ? "create" : "remove"} ${a.path}`),
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/mcp-server",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
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.3",
3
+ "version": "0.4.4",
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.3",
3
+ "version": "0.4.4",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
+ "accManagedUpdateProtocol": 2,
4
5
  "type": "module",
5
6
  "description": "Local-first coordination for independently opened AI agent sessions.",
6
7
  "keywords": [