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,108 @@
1
+ import path from "node:path";
2
+ import { openCodexAppServer, parseStableVersion } from "./app-server-client.mjs";
3
+ import { socketIsReady } from "./native-endpoint.mjs";
4
+ import { absolute, approvedProcessIsDead, failMaintenance, maintenanceContext,
5
+ managedExecutablePaths, metadataExists, probeMaintenanceInstall, readMaintenancePid, runMaintenanceCommand,
6
+ startTimeValid, verifyMaintenanceProcess } from "./maintenance-host.mjs";
7
+ import { maintenanceWorkload } from "./maintenance-threads.mjs";
8
+
9
+ const identityKeys = ["serviceId", "pid", "processStartTime", "codexHome", "cliPath", "cliVersion",
10
+ "serverVersion", "managedPath", "managedVersion", "socketPath"];
11
+ const installationKeys = ["codexHome", "cliPath", "cliVersion", "managedPath", "managedVersion", "socketPath"];
12
+ const refused = (reasonCode, snapshot) => ({ ok: false, reasonCode, ...(snapshot ? { snapshot } : {}) });
13
+ const sameFields = (left, right, fields) => fields.every(key => left?.[key] === right?.[key]);
14
+
15
+ function validApproval(value) {
16
+ return value?.serviceId === "codex-app-server" && ["ready", "busy"].includes(value.state)
17
+ && Number.isSafeInteger(value.pid) && value.pid > 1 && startTimeValid(value.processStartTime)
18
+ && ["codexHome", "cliPath", "managedPath", "socketPath"].every(key => absolute(value[key]))
19
+ && ["cliVersion", "serverVersion", "managedVersion"].every(key => parseStableVersion(value[key]))
20
+ && value.managedVersion === value.cliVersion
21
+ && managedExecutablePaths(value.codexHome).includes(value.managedPath)
22
+ && value.socketPath === path.join(value.codexHome, "app-server-control/app-server-control.sock");
23
+ }
24
+
25
+ export function sameMaintenanceIdentity(expected, current) {
26
+ return validApproval(expected) && validApproval(current) && sameFields(expected, current, identityKeys);
27
+ }
28
+
29
+ export function createCodexMaintenance({ run = runMaintenanceCommand, open = openCodexAppServer } = {}) {
30
+ async function inspectMaintenance(context) {
31
+ let snapshot = { serviceId: "codex-app-server", state: "unsupported", reasonCode: null,
32
+ pid: null, processStartTime: null, codexHome: null, cliPath: null, cliVersion: null,
33
+ serverVersion: null, managedPath: null, managedVersion: null, socketPath: null,
34
+ busyThreads: 0, queuedRequests: 0 };
35
+ try {
36
+ const paths = await maintenanceContext(context);
37
+ Object.assign(snapshot, { codexHome: paths.codexHome, socketPath: paths.socketPath, managedPath: paths.managedPath });
38
+ if (!await metadataExists(paths.socketPath) && !await metadataExists(paths.pidPath)) return null;
39
+ if (paths.platform !== "darwin-arm64") failMaintenance("maintenance_platform_unsupported");
40
+ if (!await socketIsReady(paths.socketPath)) failMaintenance("daemon_socket_unproven");
41
+ Object.assign(snapshot, await probeMaintenanceInstall(paths, run));
42
+ const version = await run(snapshot.cliPath, ["app-server", "daemon", "version"], paths.options);
43
+ if (version.status !== 0) failMaintenance("daemon_version_unavailable");
44
+ const observed = JSON.parse(version.stdout);
45
+ if (observed.status !== "running" || observed.backend !== "pid") failMaintenance("maintenance_backend_unsupported");
46
+ if (observed.managedCodexPath !== snapshot.managedPath || observed.socketPath !== snapshot.socketPath
47
+ || observed.cliVersion !== snapshot.cliVersion || observed.managedCodexVersion !== snapshot.managedVersion
48
+ || !parseStableVersion(observed.appServerVersion)) failMaintenance("daemon_identity_unavailable");
49
+ snapshot.serverVersion = observed.appServerVersion;
50
+ Object.assign(snapshot, await readMaintenancePid(paths.pidPath));
51
+ await verifyMaintenanceProcess(snapshot, paths, run);
52
+ Object.assign(snapshot, await maintenanceWorkload(snapshot, open));
53
+ const finalPid = await readMaintenancePid(paths.pidPath);
54
+ if (!sameFields(snapshot, finalPid, ["pid", "processStartTime"])) failMaintenance("service_identity_changed");
55
+ await verifyMaintenanceProcess(snapshot, paths, run);
56
+ snapshot.state = snapshot.busyThreads || snapshot.queuedRequests ? "busy" : "ready";
57
+ snapshot.reasonCode = snapshot.state === "busy" ? "service_busy" : null;
58
+ } catch (error) { snapshot.reasonCode = error.reasonCode ?? "maintenance_inspection_failed"; }
59
+ return snapshot;
60
+ }
61
+
62
+ async function stopForMaintenance({ context, expected } = {}) {
63
+ let stopAttempted = false;
64
+ const refuseStop = (reasonCode, snapshot) => ({ ...refused(reasonCode, snapshot), stopAttempted });
65
+ if (!validApproval(expected)) return refuseStop("invalid_maintenance_approval");
66
+ try {
67
+ const current = await inspectMaintenance(context);
68
+ if (!sameMaintenanceIdentity(expected, current)) return refuseStop("service_identity_changed", current);
69
+ if (current.state !== "ready" || current.busyThreads !== 0 || current.queuedRequests !== 0) {
70
+ return refuseStop("service_busy", current);
71
+ }
72
+ const paths = await maintenanceContext(context);
73
+ stopAttempted = true;
74
+ const stopped = await run(current.cliPath, ["app-server", "daemon", "stop"], paths.options);
75
+ if (stopped.status !== 0) return refuseStop("daemon_stop_failed");
76
+ if (!await approvedProcessIsDead(expected, paths, run)) return refuseStop("daemon_death_unverified");
77
+ return { ok: true, reasonCode: null, stopAttempted };
78
+ } catch { return refuseStop("daemon_stop_failed"); }
79
+ }
80
+
81
+ async function startAfterMaintenance({ context, expected } = {}) {
82
+ if (!validApproval(expected)) return refused("invalid_maintenance_approval");
83
+ try {
84
+ const pinned = { ...context, env: { ...(context?.env ?? process.env), CODEX_HOME: expected.codexHome } };
85
+ const paths = await maintenanceContext(pinned);
86
+ if (paths.platform !== "darwin-arm64") return refused("maintenance_platform_unsupported");
87
+ const install = { ...paths, ...await probeMaintenanceInstall(paths, run) };
88
+ if (!sameFields(expected, install, installationKeys)) return refused("service_identity_changed");
89
+ const existing = await inspectMaintenance(pinned);
90
+ if (existing) {
91
+ if (["ready", "busy"].includes(existing.state) && sameFields(expected, existing, installationKeys)
92
+ && existing.serverVersion === expected.cliVersion) return { ok: true, reasonCode: null, snapshot: existing };
93
+ return refused("service_identity_changed", existing);
94
+ }
95
+ if (!await approvedProcessIsDead(expected, paths, run)) return refused("daemon_death_unverified");
96
+ const started = await run(install.cliPath, ["app-server", "daemon", "start"], paths.options);
97
+ if (started.status !== 0) return refused("daemon_start_failed");
98
+ const current = await inspectMaintenance(pinned);
99
+ if (!current || !["ready", "busy"].includes(current.state)
100
+ || !sameFields(expected, current, installationKeys) || current.serverVersion !== expected.cliVersion) {
101
+ return refused("daemon_start_unverified", current);
102
+ }
103
+ return { ok: true, reasonCode: null, snapshot: current };
104
+ } catch { return refused("daemon_start_failed"); }
105
+ }
106
+
107
+ return { inspectMaintenance, stopForMaintenance, startAfterMaintenance };
108
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
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.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/adapter-grok",
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/adapter-kimi",
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/adapter-sdk",
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/cli",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -58,7 +58,7 @@ export const COMMANDS = Object.freeze({
58
58
  uninstall: { required: [], optional: ["home"], repeated: ["adapter"], flags: ["dry-run"] },
59
59
  // Asking npm whether there is a newer ACC. The one command that touches the
60
60
  // network, and never on the hook path.
61
- update: { required: [], optional: ["auto", "pin"], flags: ["check", "apply"] },
61
+ update: { required: [], optional: ["auto", "pin"], flags: ["check", "apply", "yes"] },
62
62
  // The two things a person types first after installing from a registry. The
63
63
  // CLI answered neither: `acc --version` and `acc --help` were both "unknown
64
64
  // command", and `acc` on its own asked for a command without naming one.
@@ -15,6 +15,7 @@ import { ALL_ADAPTERS, clientContext, probeTimeout } from "./install-command.mjs
15
15
  import { describePresence } from "./main.mjs";
16
16
  import { platformPaths } from "./platform-paths.mjs";
17
17
  import { noticeUpdate } from "./update-check.mjs";
18
+ import { managedUpdateDiagnostic } from "./managed-runtime/diagnostics.mjs";
18
19
  import { readControl } from "./managed-runtime/state.mjs";
19
20
  import { diagnoseFilesystemStore, repairFilesystemStore }
20
21
  from "@agents-can-communicate/storage-filesystem";
@@ -207,9 +208,7 @@ export async function runDoctor({ options, context, runtime }) {
207
208
  const update = manager === null
208
209
  ? await noticeUpdate({ dataHome, running, env: runtime?.env ?? {},
209
210
  now: Date.parse(clock.now()), get: runtime?.fetch, io: { readFile, writeFile, mkdir } })
210
- : { checked: false, running, latest: manager.pending?.version ?? null,
211
- newer: manager.pending !== null, auto: manager.auto, pin: manager.pin,
212
- pending: manager.pending?.version ?? null, phase: manager.phase, notice: manager.notice };
211
+ : await managedUpdateDiagnostic(runtime.managerRoot, manager, running);
213
212
 
214
213
  // Before the store is read for anything else. `collectStatus` reads every
215
214
  // record, so on the store this command exists to describe it threw first and
@@ -276,7 +275,7 @@ export async function runDoctor({ options, context, runtime }) {
276
275
  ...adapters.filter(adapter => adapter.present && adapter.outgoingDelivery)
277
276
  .map(adapter => ` ${adapter.displayName} ${adapter.outgoingDelivery.diagnostic}`),
278
277
  ...(manager === null ? [] : [` automatic updates ${manager.auto ? "on" : "off"}; ACC ${manager.active.version}`
279
- + (manager.pin ? `; pinned to ${manager.pin}` : ""), ...(manager.notice ? [` ${manager.notice}`] : [])]),
278
+ + (manager.pin ? `; pinned to ${manager.pin}` : ""), ...(update.notice ? [` ${update.notice}`] : [])]),
280
279
  ...data.remediation.map(line => ` ${line}`),
281
280
  // `0 of 4` is a true line that reads as a broken machine, and on an
282
281
  // MCP-only one it would read that way on every run forever. The server needs
@@ -106,6 +106,8 @@ const NOTES = Object.freeze({
106
106
  "Use acc update --check to only report whether a newer release exists.",
107
107
  "An initial acc install enables automatic updates; reinstalling preserves your choice. Activation waits for active clients and ACC processes.",
108
108
  "--auto on|off changes automatic updates; --pin VERSION holds an exact stable version; --pin none follows stable releases.",
109
+ "An eligible Codex service restart asks once; a detached worker waits for idle work and verifies completion. Open clients disconnect; acc doctor reports progress.",
110
+ "--yes explicitly consents to that restart without a prompt; --check never schedules maintenance.",
109
111
  "--apply remains accepted as a compatibility alias for acc update."],
110
112
  });
111
113
 
@@ -18,6 +18,7 @@ import { runUpdateCommand } from "./update-command.mjs";
18
18
  import { runConfigCommand } from "./config-command.mjs";
19
19
  import { runInstallCommand } from "./install-command.mjs";
20
20
  import { runDoctor } from "./doctor-command.mjs";
21
+ import { runManagementDoctor } from "./managed-runtime/diagnostics.mjs";
21
22
  import { createGitProbe } from "./git-probe.mjs";
22
23
  import { canonicalClaim } from "./claim-spelling.mjs";
23
24
  import { platformDataHome, runtimePaths } from "./runtime-paths.mjs";
@@ -412,7 +413,8 @@ export async function main(argv, runtime) {
412
413
  let parsed;
413
414
  try {
414
415
  parsed = parseArgs(argv);
415
- if (runtime.managementOnly && !["help", "version", "update"].includes(parsed.command)) {
416
+ const managementDoctor = runtime.managementOnly && parsed.command === "doctor" && !parsed.options.repair;
417
+ if (runtime.managementOnly && !managementDoctor && !["help", "version", "update"].includes(parsed.command)) {
416
418
  throw new AccError(EXIT.DATA, "runtime unavailable; retry after the update or run acc update to recover");
417
419
  }
418
420
  // `config` is the one command that must work on a workspace ACC cannot
@@ -423,7 +425,7 @@ export async function main(argv, runtime) {
423
425
  // discovery cannot open the workspace - that is what a user is trying to
424
426
  // find out - install touches client configuration rather than ACC state,
425
427
  // and `help` has to answer in a directory that is no workspace at all.
426
- const context = NO_WORKSPACE.includes(parsed.command)
428
+ const context = managementDoctor || NO_WORKSPACE.includes(parsed.command)
427
429
  ? null
428
430
  : parsed.command === "doctor"
429
431
  ? await openDiagnosticContext(parsed.options, runtime)
@@ -434,7 +436,7 @@ export async function main(argv, runtime) {
434
436
  const options = context === null ? parsed.options
435
437
  : await resolveOwner({ command: parsed.command, options: parsed.options,
436
438
  context, env: runtime.env });
437
- const { data, text, error: outcome } = await HANDLERS[parsed.command](
439
+ const { data, text, error: outcome } = await (managementDoctor ? runManagementDoctor : HANDLERS[parsed.command])(
438
440
  { options, context, runtime });
439
441
  // A handler may have done real work and still failed: `acc install` writes
440
442
  // for the clients it could and reports the one it could not. The data is
@@ -53,11 +53,49 @@ export async function listNativeHolds(root, { pidIsAlive = defaultPidIsAlive } =
53
53
  return holds;
54
54
  }
55
55
 
56
+ /** Activation calls this under the admission mutex; diagnostics are a snapshot.
57
+ * The updater may ignore its own leases, never a native client's lifetime.
58
+ * Project only process facts: leases and bindings carry private owner tokens.
59
+ */
60
+ export async function listActivationBlockers(root, { pidIsAlive = defaultPidIsAlive, ignorePid = null } = {}) {
61
+ const leases = (await listRuntimeHolds(root, { pidIsAlive })).filter(lease => lease.pid !== ignorePid);
62
+ 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 }))];
65
+ const byPid = new Map(), unidentified = [];
66
+ for (const hold of holds) {
67
+ const pid = hold.pid ?? null;
68
+ const nativeBindings = hold.nativeBindings;
69
+ if (pid === null) {
70
+ unidentified.push({ pid, kinds: [hold.kind], reason: hold.reason, nativeBindings });
71
+ continue;
72
+ }
73
+ const blocker = byPid.get(pid) ?? { pid, kinds: [], reason: "process_running", nativeBindings: 0 };
74
+ if (!blocker.kinds.includes(hold.kind)) blocker.kinds.push(hold.kind);
75
+ blocker.nativeBindings += nativeBindings;
76
+ byPid.set(pid, blocker);
77
+ }
78
+ return [...byPid.values()].sort((a, b) => a.pid - b.pid)
79
+ .map(blocker => ({ ...blocker, kinds: blocker.kinds.sort() })).concat(unidentified);
80
+ }
81
+
82
+ export function activationBlockerNotice(version, blockers) {
83
+ if (!blockers.length) return `ACC ${version} is ready; no active or unidentified processes are blocking activation.`;
84
+ const details = blockers.slice(0, 10).map(blocker => {
85
+ const facts = [blocker.kinds.map(safeText).join(", ")];
86
+ if (blocker.nativeBindings) facts.push(`${blocker.nativeBindings} native binding${blocker.nativeBindings === 1 ? "" : "s"}`);
87
+ if (blocker.pid === null) facts.push(blocker.reason.replaceAll("_", " "));
88
+ return `${blocker.pid === null ? "unidentified process" : `PID ${blocker.pid}`} (${facts.join("; ")})`;
89
+ });
90
+ return `ACC ${version} is ready; waiting for ${blockers.length} active or unidentified process(es): `
91
+ + details.join("; ") + (blockers.length > 10 ? `; and ${blockers.length - 10} more` : "") + ".";
92
+ }
93
+
56
94
  async function prepareCandidate(control, root, env) {
57
95
  const file = path.join(control.pending.root, "node_modules", "@agents-can-communicate", "cli",
58
96
  "src", "managed-runtime", "refresh.mjs");
59
97
  const { prepareRefresh } = await import(pathToFileURL(file).href);
60
- return prepareRefresh({ control, root, env });
98
+ return prepareRefresh({ control, root, env, callerProtocol: 2 });
61
99
  }
62
100
  // JSON parser diagnostics may quote private config bytes. Keep the cause and
63
101
  // known artifact paths, never the offending input or terminal control bytes.
@@ -71,7 +109,7 @@ const recipe = c => JSON.stringify([c.active, c.pending, c.home, c.targets, c.au
71
109
 
72
110
  /** Detection is outside the fence; every integration write and commit stays inside it. */
73
111
  export async function activatePending(root, { prepare = prepareCandidate, env = process.env,
74
- pidIsAlive = defaultPidIsAlive, ignorePid = null } = {}) {
112
+ pidIsAlive = defaultPidIsAlive, ignorePid = null, beforeActivation = null } = {}) {
75
113
  root = await canonicalManagerRoot(root);
76
114
  const before = await readControl(root);
77
115
  if (!before?.pending) return { activated: false, reason: "no_pending_update" };
@@ -79,12 +117,12 @@ export async function activatePending(root, { prepare = prepareCandidate, env =
79
117
  return withManagerLock(root, async () => {
80
118
  const current = await readControl(root);
81
119
  if (!current || recipe(current) !== recipe(before)) return { activated: false, reason: "state_changed" };
82
- const holds = (await listRuntimeHolds(root, { pidIsAlive })).filter(lease => lease.pid !== ignorePid);
83
- holds.push(...await listNativeHolds(root, { pidIsAlive }));
84
- if (holds.length) {
85
- const notice = `ACC ${current.pending.version} is ready; waiting for ${holds.length} active or unidentified process(es).`;
120
+ if (beforeActivation) await beforeActivation(current);
121
+ const blockers = await listActivationBlockers(root, { pidIsAlive, ignorePid });
122
+ if (blockers.length) {
123
+ const notice = activationBlockerNotice(current.pending.version, blockers);
86
124
  await writeControl(root, { ...current, notice });
87
- return { activated: false, reason: "processes_active", holds: holds.length, notice };
125
+ return { activated: false, reason: "processes_active", holds: blockers.length, blockers, notice };
88
126
  }
89
127
  const activating = { ...current, phase: "activating", notice: "Finishing integration refresh." };
90
128
  await writeControl(root, activating);
@@ -4,8 +4,13 @@ import { withManagerLock } from "./mutex.mjs";
4
4
  import { scheduleWorker } from "./schedule.mjs";
5
5
  import { readControl, writeControl } from "./state.mjs";
6
6
  import { performUpdate, runWorker } from "./worker.mjs";
7
+ import { requestMaintenance } from "./maintenance.mjs";
8
+ import { retireLegacyUpdateWorker, stageNewerManagementRuntime } from "./management-recovery.mjs";
7
9
 
8
10
  export function validateUpdateOptions(options) {
11
+ if (options.yes && (options.check || options.auto !== undefined || options.pin !== undefined)) {
12
+ throw new AccError(EXIT.USAGE, "--yes only consents to client maintenance during acc update");
13
+ }
9
14
  if (options.auto !== undefined && !["on", "off"].includes(options.auto)) {
10
15
  throw new AccError(EXIT.USAGE, "--auto expects on or off");
11
16
  }
@@ -40,9 +45,15 @@ export async function runManagedUpdate({ options, runtime }) {
40
45
  return { data: { auto: control.auto, pin: control.pin, running: control.active.version },
41
46
  text: `Automatic updates ${control.auto ? "on" : "off"}. ${control.pin ? `Pinned to ${control.pin}.` : "Following stable releases."}` };
42
47
  }
48
+ if (!options.check) await stageNewerManagementRuntime(root, runtime);
43
49
  const result = options.check
44
50
  ? await performUpdate(root, { check: true, env })
45
51
  : await runWorker(root, { force: true, env, ignorePid: process.pid });
52
+ if (!options.check && (result.reason === "processes_active" || result.reason === "maintenance_pending"
53
+ || result.activated || result.checked && !result.newer)) {
54
+ const maintenance = await requestMaintenance({ root, control: await readControl(root), options, runtime });
55
+ if (maintenance) return maintenance;
56
+ }
46
57
  const text = result.activated ? `Updated ACC to ${result.version}; integrations refreshed.`
47
58
  : result.notice && result.reason === "processes_active" ? result.notice
48
59
  : result.reason === "refresh_failed" ? [result.notice,
@@ -57,6 +68,10 @@ export async function runManagedUpdate({ options, runtime }) {
57
68
  ...(result.reason === "refresh_failed" ? { error: new AccError(EXIT.DATA, text) } : {}) };
58
69
  } catch (error) {
59
70
  if (/manager lock held/.test(error.message)) {
71
+ if (!options.check && options.auto === undefined && options.pin === undefined && !runtime.legacyWorkerRetried
72
+ && await retireLegacyUpdateWorker(root).catch(() => false)) {
73
+ return runManagedUpdate({ options, runtime: { ...runtime, legacyWorkerRetried: true } });
74
+ }
60
75
  return { data: { inProgress: true }, text: "An update is already in progress; ACC will activate it when running clients exit." };
61
76
  }
62
77
  throw error instanceof AccError ? error : new AccError(EXIT.DATA, error.message);
@@ -0,0 +1,35 @@
1
+ import { activationBlockerNotice, listActivationBlockers } from "./activation.mjs";
2
+ import { MAINTENANCE_ACTIVE, maintenanceNotice, maintenanceReport, readMaintenance } from "./maintenance-state.mjs";
3
+ import { readControl } from "./state.mjs";
4
+
5
+ /** Available when workspace admission is fenced, and from a newer management
6
+ * implementation before activation. Never opens a workspace or probes clients.
7
+ */
8
+ export async function runManagementDoctor({ runtime }) {
9
+ const manager = await readControl(runtime.managerRoot);
10
+ if (!manager) throw new Error("managed update state is unavailable; run acc update to recover");
11
+ const update = await managedUpdateDiagnostic(runtime.managerRoot, manager, manager.active.version);
12
+ return { data: { scope: "update", workspaceInspection: "unavailable_during_update", update,
13
+ remediation: ["acc update"] },
14
+ text: ["Update diagnostics only; workspace and integrations were not inspected.",
15
+ update.notice ?? `ACC ${manager.active.version}; update phase ${manager.phase}.`,
16
+ "Run acc update to finish, then acc doctor for the full workspace report."].join("\n") };
17
+ }
18
+
19
+ export async function managedUpdateDiagnostic(root, manager, running) {
20
+ const blockers = manager.pending ? await listActivationBlockers(root, { ignorePid: process.pid }) : null;
21
+ const update = { checked: false, running, latest: manager.pending?.version ?? null,
22
+ newer: manager.pending !== null, auto: manager.auto, pin: manager.pin,
23
+ pending: manager.pending?.version ?? null, phase: manager.phase,
24
+ notice: blockers === null ? manager.notice : manager.phase === "activating" && !blockers.length
25
+ ? "Integration refresh is incomplete; run acc update to recover."
26
+ : activationBlockerNotice(manager.pending.version, blockers),
27
+ ...(blockers === null ? {} : { holds: blockers.length, blockers }) };
28
+ const maintenance = await readMaintenance(root);
29
+ if (maintenance && (maintenance.target.root === (manager.pending ?? manager.active).root
30
+ || MAINTENANCE_ACTIVE.includes(maintenance.status))) {
31
+ update.maintenance = maintenanceReport(maintenance);
32
+ update.notice = maintenanceNotice(maintenance);
33
+ }
34
+ return update;
35
+ }
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { fileURLToPath, pathToFileURL } from "node:url";
4
4
  import { scheduleWorker } from "./schedule.mjs";
5
5
  import { acquireRuntime } from "./leases.mjs";
6
- import { canonicalManagerRoot, readControl } from "./state.mjs";
6
+ import { canonicalManagerRoot, readControl, readManagedJson } from "./state.mjs";
7
7
 
8
8
  export const ENTRY_KINDS = Object.freeze([
9
9
  "acc", "acc-hook", "acc-mcp", "acc-bootstrap", "acc-claude-channel",
@@ -53,6 +53,31 @@ function unavailable(kind) {
53
53
  }
54
54
  }
55
55
 
56
+ const stableVersion = value => typeof value === "string" && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value);
57
+ function newerVersion(left, right) {
58
+ if (!stableVersion(left) || !stableVersion(right)) return false;
59
+ const a = left.split(".").map(BigInt), b = right.split(".").map(BigInt);
60
+ for (let index = 0; index < 3; index++) if (a[index] !== b[index]) return a[index] > b[index];
61
+ return false;
62
+ }
63
+
64
+ /** Pending means downloaded and verified. Older packages do not advertise this
65
+ * management-only protocol; never load them speculatively for workspace work.
66
+ */
67
+ async function updateImplementation(packageRoot, control) {
68
+ let selected = null;
69
+ for (const candidate of [{ root: packageRoot }, control.pending].filter(candidate => candidate?.root)) {
70
+ try {
71
+ const manifest = await readManagedJson(path.join(candidate.root, "package.json"));
72
+ if (manifest?.name !== "agents-can-communicate" || manifest.accManagedUpdateProtocol !== 2
73
+ || candidate.version !== undefined && manifest.version !== candidate.version
74
+ || !newerVersion(manifest.version, selected?.version ?? control.active.version)) continue;
75
+ selected = { root: candidate.root, version: manifest.version };
76
+ } catch { /* Unreadable alternatives leave the active implementation available. */ }
77
+ }
78
+ return selected?.root ?? null;
79
+ }
80
+
56
81
  /** The lease lasts until OS process death, including callbacks after main returns. */
57
82
  export async function runEntry({ kind, packageRoot, managerRoot, managedRequired = false }) {
58
83
  if (!ENTRY_KINDS.includes(kind)) throw new Error("unknown ACC entry point");
@@ -66,6 +91,7 @@ export async function runEntry({ kind, packageRoot, managerRoot, managedRequired
66
91
  }
67
92
  let selected = packageRoot;
68
93
  let managed = null;
94
+ let managementOnly = false;
69
95
  let root;
70
96
  try {
71
97
  const requestedDataHome = bootstrapOptions === null ? null : await canonicalManagerRoot(bootstrapOptions.dataHome);
@@ -81,12 +107,18 @@ export async function runEntry({ kind, packageRoot, managerRoot, managedRequired
81
107
  if (control !== null) {
82
108
  selected = control.active.root;
83
109
  managed = root;
84
- const lease = await acquireRuntime(root, { pid: process.pid, kind });
85
- selected = lease.runtime.root;
86
- managed = root;
87
- const quiet = kind === "acc" && ["update", "install", "uninstall", "help", "version",
88
- "--help", "-h", "--version", "-v", "-V"].includes(process.argv[2]);
89
- if (!quiet) await scheduleWorker(root, control);
110
+ const update = kind === "acc" && ["update", "doctor"].includes(process.argv[2])
111
+ ? await updateImplementation(packageRoot, control) : null;
112
+ if (update !== null) {
113
+ selected = update;
114
+ managementOnly = true;
115
+ } else {
116
+ const lease = await acquireRuntime(root, { pid: process.pid, kind });
117
+ selected = lease.runtime.root;
118
+ const quiet = kind === "acc" && ["update", "install", "uninstall", "help", "version",
119
+ "--help", "-h", "--version", "-v", "-V"].includes(process.argv[2]);
120
+ if (!quiet) await scheduleWorker(root, control);
121
+ }
90
122
  } else if (managedRequired) throw new Error("managed runtime is not initialized");
91
123
  } catch {
92
124
  // Help and update recovery must remain reachable when state cannot admit a
@@ -106,5 +138,5 @@ export async function runEntry({ kind, packageRoot, managerRoot, managedRequired
106
138
  return;
107
139
  }
108
140
  const runtime = await import(pathToFileURL(path.join(selected, "bin", "entrypoints", `${kind}.mjs`)).href);
109
- await runtime.main({ managerRoot: managed, packageRoot: selected });
141
+ await runtime.main({ managerRoot: managed, packageRoot: selected, ...(managementOnly ? { managementOnly } : {}) });
110
142
  }
@@ -0,0 +1,47 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { askConfirmation } from "../confirm.mjs";
4
+ import { ALL_ADAPTERS } from "../install-command.mjs";
5
+ import { MAINTENANCE_ACTIVE, readMaintenance } from "./maintenance-state.mjs";
6
+
7
+ const inspectServices = async options => (await import("./maintenance.mjs")).inspectMaintenanceServices(options);
8
+ const request = async options => (await import("./maintenance.mjs")).requestMaintenance(options);
9
+ const handoff = () => Object.assign(new Error("ACC maintenance needs confirmation or recovery; run acc update."),
10
+ { code: "ACC_LEGACY_MAINTENANCE_HANDOFF" });
11
+
12
+ async function isLegacyWorker(argv, control, root) {
13
+ if (argv.length !== 3) return false;
14
+ try {
15
+ return await realpath(argv[1]) === await realpath(path.join(control.active.root, "bin", "acc-update-worker.mjs"))
16
+ && await realpath(argv[2]) === await realpath(root);
17
+ } catch { return false; }
18
+ }
19
+
20
+ /** Old activation imports this candidate before taking its admission fence.
21
+ * Only request/schedule here: the detached job waits for this process to exit.
22
+ * Throwing in the old background worker unwinds its idle-loop lock normally.
23
+ */
24
+ export async function bridgeLegacyMaintenance({ control, root, env = process.env, callerProtocol = 1 }, {
25
+ argv = process.argv, input = process.stdin, output = process.stdout,
26
+ inspect = inspectServices, requestMaintenance = request,
27
+ } = {}) {
28
+ if (callerProtocol >= 2) return null;
29
+ const worker = await isLegacyWorker(argv, control, root);
30
+ const interactive = argv[2] === "update" && !argv.includes("--json")
31
+ && input.isTTY === true && output.isTTY === true;
32
+ if (!worker && !interactive) return null;
33
+ const existing = await readMaintenance(root);
34
+ let services;
35
+ if (!MAINTENANCE_ACTIVE.includes(existing?.status)) {
36
+ if (!control.pending) return null;
37
+ services = await inspect({ root, control, env, adapters: ALL_ADAPTERS() });
38
+ if (!services.length) return null;
39
+ if (worker) throw handoff();
40
+ }
41
+ const result = await requestMaintenance({ root, control, options: { json: false }, services,
42
+ runtime: { env, input, output, confirm: askConfirmation,
43
+ isInteractive: () => interactive, packageRoot: control.pending?.root ?? control.active.root } });
44
+ if (worker) throw handoff();
45
+ if (result?.text) output.write(`${result.text}\n`);
46
+ return result;
47
+ }
@@ -0,0 +1,57 @@
1
+ import path from "node:path";
2
+ import { managedDirectory, readManagedJson, validateRuntime, writeManagedJson } from "./state.mjs";
3
+
4
+ export const MAINTENANCE_ACTIVE = ["waiting", "stopping", "refreshing", "restarting", "recovery"];
5
+ const statuses = [...MAINTENANCE_ACTIVE, "completed", "failed", "cancelled"];
6
+ export const maintenanceRecipe = c => JSON.stringify([c.active, c.pending, c.home, c.targets, c.auto, c.pin]);
7
+
8
+ export async function readMaintenance(root) {
9
+ if (!await managedDirectory(root)) return null;
10
+ const value = await readManagedJson(path.join(root, "maintenance.json"));
11
+ if (value === undefined) return null;
12
+ if (value.schemaVersion !== 1 || typeof value.id !== "string" || !/^[a-f0-9-]{36}$/.test(value.id)
13
+ || !statuses.includes(value.status) || typeof value.recipe !== "string"
14
+ || typeof value.workerRoot !== "string" || !path.isAbsolute(value.workerRoot)
15
+ || !Number.isFinite(value.deadline) || !Number.isSafeInteger(value.callerPid) || value.callerPid < 1
16
+ || !(value.workerPid === null || Number.isSafeInteger(value.workerPid) && value.workerPid > 0)
17
+ || !Array.isArray(value.services) || value.services.length !== 1
18
+ || !value.services.every(s => typeof s.adapterId === "string" && s.snapshot
19
+ && Number.isSafeInteger(s.snapshot.pid) && s.snapshot.pid > 0)
20
+ || !Array.isArray(value.attempted) || !value.attempted.every(id => value.services.some(s => s.adapterId === id))) {
21
+ throw new Error("invalid update maintenance record");
22
+ }
23
+ await validateRuntime(root, value.target);
24
+ return value;
25
+ }
26
+
27
+ /** Caller holds the admission mutex or the exclusive maintenance-worker lock. */
28
+ export async function writeMaintenance(root, job) {
29
+ await writeManagedJson(path.join(root, "maintenance.json"), job);
30
+ return job;
31
+ }
32
+
33
+ export function maintenanceReport(job) {
34
+ return { id: job.id, status: job.status, version: job.target.version,
35
+ reasonCode: job.reasonCode ?? null, workerPid: job.workerPid,
36
+ ...(job.status === "waiting" && job.waiting ? { waiting: job.waiting } : {}),
37
+ services: job.services.map(({ adapterId, snapshot }) => ({ adapterId,
38
+ serviceId: snapshot.serviceId, pid: snapshot.pid, clientVersion: snapshot.cliVersion,
39
+ serverVersion: snapshot.serverVersion })) };
40
+ }
41
+
42
+ export function maintenanceNotice(job) {
43
+ const version = job.target.version;
44
+ if (job.status === "completed") return `ACC ${version} update maintenance completed; the client service was restarted and verified.`;
45
+ if (job.status === "cancelled") return `Update maintenance cancelled (${job.reasonCode}); no client service was stopped.`;
46
+ if (job.status === "failed") return `Update maintenance failed (${job.reasonCode}); run acc update to retry.`;
47
+ if (job.status === "recovery") return `Client service recovery is pending (${job.reasonCode}); run acc update to retry recovery.`;
48
+ if (job.status === "waiting") {
49
+ const waiting = job.waiting?.reason === "service_busy" ? `idle client work (${job.waiting.busyThreads ?? "unknown"} active turns, ${job.waiting.queuedRequests ?? "unknown"} queued requests)`
50
+ : job.waiting?.reason === "other_processes" ? `${job.waiting.processes} other or unidentified process(es) to exit`
51
+ : job.waiting?.reason === "caller_exit" ? "the requesting command to exit"
52
+ : job.waiting?.reason === "update_worker" ? "another ACC updater"
53
+ : "idle clients and other ACC processes to exit";
54
+ return `ACC ${version} update maintenance is scheduled; waiting for ${waiting}. Open clients will disconnect during restart. Check acc doctor for progress.`;
55
+ }
56
+ return `ACC ${version} update maintenance: ${job.status}. Check acc doctor for the result.`;
57
+ }