agents-can-communicate 0.4.3 → 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 (71) hide show
  1. package/README.md +8 -4
  2. package/bin/acc-maintenance-worker.mjs +6 -0
  3. package/bin/entrypoints/acc-hook.mjs +58 -5
  4. package/docs/ARCHITECTURE.md +21 -9
  5. package/docs/CLI.md +21 -10
  6. package/docs/CONCEPTS.md +2 -1
  7. package/docs/CONFIGURATION.md +2 -1
  8. package/docs/GETTING_STARTED.md +4 -2
  9. package/docs/HOW_IT_WORKS.md +2 -1
  10. package/docs/PROTOCOL.md +7 -0
  11. package/docs/RELEASING.md +1 -1
  12. package/docs/SECURITY_MODEL.md +16 -7
  13. package/docs/TROUBLESHOOTING.md +13 -6
  14. package/docs/UPGRADING.md +121 -22
  15. package/docs/WHY_ACC.md +1 -1
  16. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  17. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  18. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +10 -4
  19. package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +4 -0
  20. package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-host.mjs +120 -0
  21. package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-threads.mjs +49 -0
  22. package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance.mjs +108 -0
  23. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +67 -22
  24. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  25. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  26. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  27. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  28. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  29. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -1
  30. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +63 -16
  31. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +16 -1
  32. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  33. package/node_modules/@agents-can-communicate/cli/src/args.mjs +1 -1
  34. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +10 -5
  35. package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -0
  36. package/node_modules/@agents-can-communicate/cli/src/index.mjs +5 -0
  37. package/node_modules/@agents-can-communicate/cli/src/main.mjs +5 -3
  38. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +225 -11
  39. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/command.mjs +15 -0
  40. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/diagnostics.mjs +36 -0
  41. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/download.mjs +17 -2
  42. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +54 -14
  43. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/generation.mjs +44 -25
  44. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/install.mjs +46 -29
  45. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/legacy-maintenance.mjs +47 -0
  46. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance-state.mjs +57 -0
  47. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance-worker.mjs +118 -0
  48. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance.mjs +140 -0
  49. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/management-recovery.mjs +80 -0
  50. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/pins.mjs +125 -0
  51. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/refresh.mjs +6 -7
  52. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/retire.mjs +56 -0
  53. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +4 -2
  54. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/staging.mjs +95 -0
  55. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/state.mjs +8 -1
  56. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +34 -12
  57. package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +83 -7
  58. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  59. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  60. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +44 -2
  61. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  62. package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +10 -1
  63. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +55 -6
  64. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  65. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +2 -2
  66. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +10 -3
  67. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  68. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  69. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  70. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +38 -4
  71. package/package.json +3 -1
@@ -2,22 +2,27 @@ import { defineAdapter, projectContext, projectContextResult }
2
2
  from "@agents-can-communicate/adapter-sdk";
3
3
  import { probeNativeDelivery, planNativeActivation, bindNativeSession, refreshNativeSession, retireNativeSession, offerMessage } from "./native-delivery.mjs";
4
4
  import certification from "../certification.json" with { type: "json" };
5
+ import { createCodexMaintenance } from "./maintenance.mjs";
6
+ export { sameMaintenanceIdentity } from "./maintenance.mjs";
5
7
 
6
- import { PROTOCOL_CONTRACT } from "./app-server-client.mjs";
8
+ import { CODEX_QUEUE_MINIMUM, PROTOCOL_CONTRACT } from "./app-server-client.mjs";
7
9
  import { allowOutcome, denyOutcome, injectOutcome, normalizeCodexHook }
8
10
  from "./hooks.mjs";
9
11
  import { planCodexInstall, detectCodex, installCodexPlugin, preflightCodexUninstall,
10
12
  uninstallCodexPlugin } from "./install.mjs";
11
13
  // Public native wiring is enabled only with installed-product capture evidence.
12
14
 
15
+ // Re-exported so existing importers of CODEX_QUEUE_MINIMUM keep resolving; the
16
+ // value now lives in app-server-client.mjs alongside the other version rules.
17
+ export { CODEX_QUEUE_MINIMUM };
13
18
  export const CODEX_VERSION = "0.147.0";
14
- export const CODEX_QUEUE_MINIMUM = "0.152.1";
15
19
  export const CODEX_DELIVERY_FALLBACK = Object.freeze({
16
20
  diagnostic: "Codex native delivery requires codex-cli 0.152.1 or newer on darwin-arm64, "
17
21
  + "recorded recipient consent and a reachable LocalDaemon session with exact thread, cwd, "
18
22
  + "process, version and protocol verification. Embedded or unreachable sessions retain "
19
- + "durable messages. ACC does not start, restart or stop the vendor daemon and adds no "
20
- + "launch arguments; fallback is exact-certified next-turn delivery or acc inbox",
23
+ + "durable messages. Native delivery does not start, restart or stop the vendor daemon and adds no "
24
+ + "launch arguments; fallback is exact-certified next-turn delivery or acc inbox. "
25
+ + "Explicitly confirmed acc update maintenance has a separate daemon restart check",
21
26
  });
22
27
 
23
28
  /**
@@ -61,6 +66,7 @@ export function createCodexAdapter() {
61
66
  },
62
67
  probeNativeDelivery, planNativeActivation, bindNativeSession,
63
68
  refreshNativeSession, retireNativeSession, offerMessage,
69
+ ...createCodexMaintenance(),
64
70
  startSession: async () => ({ ok: true, changes: [], diagnostics: [] }),
65
71
  endSession: async () => ({ ok: true, changes: [], diagnostics: [] }),
66
72
  guardWrite: async () => ({ ok: true, changes: [], diagnostics: [] }),
@@ -12,6 +12,10 @@ import { openWebSocketPeer } from "./ws-json-rpc.mjs";
12
12
 
13
13
  export const PROTOCOL_CONTRACT = "codex-app-server-thread-queue-v1";
14
14
  export const MINIMUM_VERSION = "0.152.1";
15
+ // The captured native-delivery contract's floor. Shared with MINIMUM_VERSION
16
+ // today, but named separately: this is the version a binding was verified
17
+ // against, not the probe's own support floor.
18
+ export const CODEX_QUEUE_MINIMUM = MINIMUM_VERSION;
15
19
  export const QUEUE_MODES = Object.freeze(["livePush", "idleWake", "busyQueue"]);
16
20
  const CLIENT_INFO = Object.freeze({ name: "agents-can-communicate", version: "0.2.0" });
17
21
  const STABLE_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
@@ -0,0 +1,120 @@
1
+ import { execFile } from "node:child_process";
2
+ import { constants } from "node:fs";
3
+ import { access, lstat, open, realpath } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { compareStableVersions, parseStableVersion } from "./app-server-client.mjs";
7
+
8
+ export const MINIMUM_MAINTENANCE_CLI = "0.154.0";
9
+ export const absolute = value => typeof value === "string" && path.isAbsolute(value)
10
+ && !/[\0\r\n]/.test(value);
11
+ export const failMaintenance = reasonCode => { throw Object.assign(new Error(reasonCode), { reasonCode }); };
12
+ const own = info => typeof process.getuid !== "function" || info.uid === process.getuid();
13
+ export const startTimeValid = value => typeof value === "string"
14
+ && /^[A-Z][a-z]{2} [A-Z][a-z]{2} [ \d]\d \d\d:\d\d:\d\d \d{4}$/.test(value);
15
+ export const managedExecutablePaths = codexHome => ["bin/codex", "codex"].map(name =>
16
+ path.join(codexHome, "packages/standalone/current", name));
17
+
18
+ export function runMaintenanceCommand(command, args, options) {
19
+ return new Promise(resolve => execFile(command, args,
20
+ { ...options, timeout: 20_000, maxBuffer: 131_072, windowsHide: true },
21
+ (error, stdout, stderr) => resolve({ status: error ?
22
+ (typeof error.code === "number" ? error.code : null) : 0, stdout, stderr })));
23
+ }
24
+
25
+ export async function maintenanceContext(context = {}) {
26
+ const env = context.env ?? process.env;
27
+ const home = context.home ?? env.HOME ?? os.homedir();
28
+ let codexHome = env.CODEX_HOME ?? path.join(home, ".codex");
29
+ if (!absolute(home) || !absolute(codexHome)) failMaintenance("invalid_service_home");
30
+ try { codexHome = await realpath(codexHome); }
31
+ catch (error) { if (error.code !== "ENOENT") throw error; }
32
+ const [binPath, legacyPath] = managedExecutablePaths(codexHome);
33
+ const managedPath = await metadataExists(binPath) ? binPath : legacyPath;
34
+ return { codexHome, socketPath: path.join(codexHome, "app-server-control/app-server-control.sock"),
35
+ pidPath: path.join(codexHome, "app-server-daemon/app-server.pid"),
36
+ managedPath,
37
+ platform: context.platform ?? `${process.platform}-${process.arch}`,
38
+ options: { cwd: codexHome, env: { ...env, HOME: home, CODEX_HOME: codexHome } } };
39
+ }
40
+
41
+ export async function metadataExists(file) {
42
+ try { await lstat(file); return true; }
43
+ catch (error) { if (error.code === "ENOENT") return false; throw error; }
44
+ }
45
+
46
+ async function resolveCli(pathEnv) {
47
+ for (const directory of String(pathEnv ?? "").split(path.delimiter)) {
48
+ if (!absolute(directory)) continue;
49
+ const candidate = path.join(directory, "codex");
50
+ try {
51
+ await access(candidate, constants.X_OK);
52
+ const resolved = await realpath(candidate);
53
+ if ((await lstat(resolved)).isFile()) return resolved;
54
+ } catch { /* another PATH entry may be usable */ }
55
+ }
56
+ failMaintenance("maintenance_cli_unavailable");
57
+ }
58
+
59
+ const cliVersion = response => response.status === 0
60
+ ? /^codex-cli (\d+\.\d+\.\d+)\s*$/.exec(response.stdout)?.[1] ?? null : null;
61
+
62
+ export async function probeMaintenanceInstall(paths, run) {
63
+ const cliPath = await resolveCli(paths.options.env.PATH);
64
+ const version = cliVersion(await run(cliPath, ["--version"], paths.options));
65
+ if (!parseStableVersion(version) || compareStableVersions(version, MINIMUM_MAINTENANCE_CLI) < 0) {
66
+ failMaintenance("maintenance_cli_unsupported");
67
+ }
68
+ const help = await run(cliPath, ["app-server", "daemon", "--help"], paths.options);
69
+ if (help.status !== 0 || ["start", "stop", "version"].some(command =>
70
+ !new RegExp(`^\\s+${command}\\s+`, "m").test(help.stdout))) failMaintenance("maintenance_cli_unsupported");
71
+ const managedVersion = cliVersion(await run(paths.managedPath, ["--version"], paths.options));
72
+ if (managedVersion !== version) failMaintenance("managed_binary_mismatch");
73
+ return { cliPath, cliVersion: version, managedVersion };
74
+ }
75
+
76
+ export async function readMaintenancePid(file) {
77
+ let handle;
78
+ try {
79
+ handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
80
+ const info = await handle.stat();
81
+ if (!info.isFile() || !own(info) || info.size > 4_096 || (info.mode & 0o022) !== 0) {
82
+ failMaintenance("daemon_identity_unavailable");
83
+ }
84
+ const value = JSON.parse(await handle.readFile("utf8"));
85
+ if (!Number.isSafeInteger(value.pid) || value.pid < 2 || !startTimeValid(value.processStartTime)) {
86
+ failMaintenance("daemon_identity_unavailable");
87
+ }
88
+ return { pid: value.pid, processStartTime: value.processStartTime };
89
+ } finally { await handle?.close(); }
90
+ }
91
+
92
+ export async function observeMaintenanceProcess(pid, paths, run) {
93
+ const response = await run("/bin/ps", ["-p", String(pid), "-o", "lstart=,command="], paths.options);
94
+ if (response.status === 1 && response.stdout === "" && response.stderr === "") return { state: "dead" };
95
+ if (response.status !== 0) return { state: "unknown" };
96
+ const match = /^(.{24})\s+(.+)$/.exec(response.stdout.trim());
97
+ if (!match || !startTimeValid(match[1])) return { state: "unknown" };
98
+ return { state: "alive", processStartTime: match[1], command: match[2] };
99
+ }
100
+
101
+ export async function verifyMaintenanceProcess(snapshot, paths, run) {
102
+ const process = await observeMaintenanceProcess(snapshot.pid, paths, run);
103
+ const suffix = " app-server --listen unix://";
104
+ const executable = process.command?.endsWith(suffix) ? process.command.slice(0, -suffix.length) : null;
105
+ if (process.state !== "alive" || process.processStartTime !== snapshot.processStartTime
106
+ || !managedExecutablePaths(paths.codexHome).includes(executable)
107
+ || await realpath(executable) !== await realpath(paths.managedPath)) {
108
+ failMaintenance("daemon_identity_unavailable");
109
+ }
110
+ const sockets = await run("/usr/sbin/lsof", ["-n", "-a", "-p", String(snapshot.pid), "-U", "-Fn"], paths.options);
111
+ if (sockets.status !== 0 || !sockets.stdout.split("\n").includes(`n${paths.socketPath}`)) {
112
+ failMaintenance("daemon_socket_unproven");
113
+ }
114
+ }
115
+
116
+ export async function approvedProcessIsDead(expected, paths, run) {
117
+ const observed = await observeMaintenanceProcess(expected.pid, paths, run);
118
+ return observed.state === "dead" || (observed.state === "alive"
119
+ && observed.processStartTime !== expected.processStartTime);
120
+ }
@@ -0,0 +1,49 @@
1
+ import { initializeCodex } from "./app-server-client.mjs";
2
+ import { failMaintenance } from "./maintenance-host.mjs";
3
+
4
+ async function loadedThreads(peer) {
5
+ const ids = [], cursors = new Set();
6
+ let cursor = null;
7
+ for (let page = 0; page < 20; page += 1) {
8
+ const response = await peer.request("thread/loaded/list", { limit: 100, ...(cursor ? { cursor } : {}) });
9
+ if (!Array.isArray(response?.data) || response.data.some(id => typeof id !== "string" || !id)) {
10
+ failMaintenance("maintenance_protocol_unavailable");
11
+ }
12
+ ids.push(...response.data);
13
+ cursor = response.nextCursor ?? null;
14
+ if (cursor === null) {
15
+ if (new Set(ids).size !== ids.length) failMaintenance("maintenance_protocol_unavailable");
16
+ return ids.sort();
17
+ }
18
+ if (typeof cursor !== "string" || !cursor || cursors.has(cursor)) failMaintenance("maintenance_protocol_unavailable");
19
+ cursors.add(cursor);
20
+ }
21
+ failMaintenance("maintenance_protocol_unavailable");
22
+ }
23
+
24
+ // The caller receives counts only. No thread/list previews, turns, queue bodies,
25
+ // notification history, or vendor errors are retained in maintenance snapshots.
26
+ export async function maintenanceWorkload(snapshot, open) {
27
+ let peer;
28
+ try {
29
+ peer = await open({ socketPath: snapshot.socketPath });
30
+ if (await initializeCodex(peer) !== snapshot.serverVersion) failMaintenance("daemon_version_changed");
31
+ const ids = await loadedThreads(peer);
32
+ let busyThreads = 0, queuedRequests = 0;
33
+ for (const threadId of ids) {
34
+ const response = await peer.request("thread/read", { threadId, includeTurns: false });
35
+ const thread = response?.thread;
36
+ if (thread?.id !== threadId || !Array.isArray(thread.turns) || thread.turns.length !== 0
37
+ || !["idle", "active"].includes(thread.status?.type)) failMaintenance("maintenance_protocol_unavailable");
38
+ if (thread.status.type === "active") busyThreads += 1;
39
+ const queue = await peer.request("thread/queue/list", { threadId });
40
+ if (!Array.isArray(queue?.data) || queue.nextCursor != null
41
+ || queue.data.some(entry => typeof entry?.id !== "string" || !entry.id)) {
42
+ failMaintenance("maintenance_protocol_unavailable");
43
+ }
44
+ queuedRequests += queue.data.length;
45
+ }
46
+ if (JSON.stringify(await loadedThreads(peer)) !== JSON.stringify(ids)) failMaintenance("loaded_threads_changed");
47
+ return { busyThreads, queuedRequests };
48
+ } finally { await peer?.close(); }
49
+ }
@@ -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,9 +1,10 @@
1
1
  import { realpath } from "node:fs/promises";
2
2
  import { decisionBody } from "@agents-can-communicate/adapter-sdk";
3
3
 
4
- import { MINIMUM_VERSION, PROTOCOL_CONTRACT, QUEUE_MODES, addCodexQueueMessage,
5
- canonicalCwd, controlSocketPath, locateCodexThread, openCodexAppServer,
6
- parseStableVersion, probeCodexQueue, safeReason, serverVersionOf } from "./app-server-client.mjs";
4
+ import { CODEX_QUEUE_MINIMUM, MINIMUM_VERSION, PROTOCOL_CONTRACT, QUEUE_MODES,
5
+ addCodexQueueMessage, canonicalCwd, compareStableVersions, controlSocketPath,
6
+ locateCodexThread, openCodexAppServer, parseStableVersion, probeCodexQueue,
7
+ safeReason, serverVersionOf } from "./app-server-client.mjs";
7
8
  import { newEndpointId, readNativeEndpoint, removeNativeEndpoint, socketIsReady,
8
9
  writeNativeEndpoint } from "./native-endpoint.mjs";
9
10
 
@@ -62,13 +63,35 @@ export function planNativeActivation({ detection }) {
62
63
  ] };
63
64
  }
64
65
 
65
- async function verifyReceiver(peer, endpoint) {
66
- const probe = await probeCodexQueue(peer, { threadId: endpoint.threadId });
67
- if (!probe.supported) return probe.reasonCode === "probe_timeout" ? "handshake_timeout" : "protocol_mismatch";
68
- if (probe.serverVersion !== endpoint.clientVersion) return "handshake_version_mismatch";
69
- const located = await locateCodexThread(peer, { threadId: endpoint.threadId, cwd: endpoint.cwd });
70
- return located.found ? null : located.reasonCode === "cwd_mismatch"
71
- ? "workspace_identity_unavailable" : "handshake_failed";
66
+ // A daemon that restarted onto a newer build still satisfies the captured
67
+ // contract. The thread it may have lost is reported separately by
68
+ // locateCodexThread, so refusing on the version alone only hides the real
69
+ // reason. Returns the version that actually answered as `servingVersion`
70
+ // (null when refused) instead of mutating the endpoint it was handed, so a
71
+ // caller can decide what to persist on the one path that owns persistence,
72
+ // after its own validation has already run.
73
+ export async function verifyReceiver(peer, endpoint, { probe = probeCodexQueue,
74
+ locate = locateCodexThread } = {}) {
75
+ const result = await probe(peer, { threadId: endpoint.threadId });
76
+ if (!result.supported) {
77
+ // probeCodexQueue applies the same floor and answers first, so a daemon
78
+ // serving below the minimum arrives here as a refused probe rather than
79
+ // reaching the explicit check below. Reporting that as a protocol
80
+ // mismatch named the wrong condition everywhere it surfaced - doctor,
81
+ // the offer's safe error code, the recorded failure - and left the
82
+ // version answer this function already knows how to give unreachable.
83
+ return { reasonCode: result.reasonCode === "probe_timeout" ? "handshake_timeout"
84
+ : result.reasonCode === "below_minimum_version" ? "handshake_version_mismatch"
85
+ : "protocol_mismatch", servingVersion: null };
86
+ }
87
+ if (compareStableVersions(result.serverVersion, CODEX_QUEUE_MINIMUM) < 0) {
88
+ return { reasonCode: "handshake_version_mismatch", servingVersion: null };
89
+ }
90
+ const located = await locate(peer, { threadId: endpoint.threadId, cwd: endpoint.cwd });
91
+ return located.found
92
+ ? { reasonCode: null, servingVersion: result.serverVersion }
93
+ : { reasonCode: located.reasonCode === "cwd_mismatch" ? "workspace_identity_unavailable" : "handshake_failed",
94
+ servingVersion: null };
72
95
  }
73
96
 
74
97
  export async function bindNativeSession({ event, clientPid, clientVersion, runtimeDir,
@@ -86,20 +109,41 @@ export async function bindNativeSession({ event, clientPid, clientVersion, runti
86
109
  const endpoint = { schemaVersion: 1, endpointId: newEndpointId(), socketPath,
87
110
  threadId: event.sessionId, cwd, clientVersion, protocolContract: PROTOCOL_CONTRACT,
88
111
  leaseUntil: new Date(now() + LEASE_MS).toISOString() };
89
- const reason = await verifyReceiver(peer, endpoint);
90
- if (reason !== null) return rejected(reason);
91
- await writeNativeEndpoint({ runtimeDir, record: endpoint });
92
- return handshake(endpoint, now);
112
+ const { reasonCode, servingVersion } = await verifyReceiver(peer, endpoint);
113
+ if (reasonCode !== null) return rejected(reasonCode);
114
+ // The daemon may have restarted onto a different build since the
115
+ // caller's claimed clientVersion was captured; persist what actually
116
+ // answered so the binding and its later reads reflect the serving
117
+ // process, not the caller's claim.
118
+ const verified = { ...endpoint, clientVersion: servingVersion };
119
+ await writeNativeEndpoint({ runtimeDir, record: verified });
120
+ return handshake(verified, now);
93
121
  });
94
122
  } catch (error) {
95
123
  return rejected(error?.code === "ETIMEDOUT" ? "handshake_timeout" : "handshake_failed");
96
124
  }
97
125
  }
98
126
 
127
+ // Resolving the binding's opaque reference is the whole of the identity check:
128
+ // the endpoint id is 128 random bits minted per bind, readNativeEndpoint
129
+ // re-checks that the record it read carries that exact id, and no two bindings
130
+ // can name the same record.
131
+ //
132
+ // The two records' clientVersion snapshots used to be compared here as well.
133
+ // That was never identity - the id already settles that - it was a third
134
+ // version rule, and the only one applied to a stored snapshot rather than to
135
+ // the process now answering. It is deliberately gone. It cannot refuse
136
+ // anything verifyReceiver does not refuse better two lines on, against the
137
+ // version that actually answers, with the thread and cwd, before anything is
138
+ // queued; and against the snapshot it could only ever be vacuous, because a
139
+ // record is written solely from a serving version that already cleared
140
+ // CODEX_QUEUE_MINIMUM. What it did do was fire on the case 0.5.0 exists to
141
+ // support: the endpoint records what the daemon serves, the binding recorded
142
+ // what `codex --version` printed, and a daemon that had updated under its CLI
143
+ // was refused here on that difference alone.
99
144
  async function receiverFor(binding, runtimeDir) {
100
145
  const endpoint = await readNativeEndpoint({ runtimeDir, endpointId: binding?.opaqueEndpointRef });
101
- return endpoint?.clientVersion === binding?.clientVersion
102
- && await socketIsReady(endpoint?.socketPath) ? endpoint : null;
146
+ return endpoint !== null && await socketIsReady(endpoint.socketPath) ? endpoint : null;
103
147
  }
104
148
 
105
149
  export async function refreshNativeSession({ binding, runtimeDir, timeoutMs = 750,
@@ -109,8 +153,9 @@ export async function refreshNativeSession({ binding, runtimeDir, timeoutMs = 75
109
153
  if (endpoint === null) return rejected("handshake_failed");
110
154
  try {
111
155
  return await usingPeer(endpoint.socketPath, timeoutMs, open, async peer => {
112
- const reason = await verifyReceiver(peer, endpoint);
113
- return reason === null ? handshake(endpoint, now) : rejected(reason);
156
+ const { reasonCode, servingVersion } = await verifyReceiver(peer, endpoint);
157
+ return reasonCode === null
158
+ ? handshake({ ...endpoint, clientVersion: servingVersion }, now) : rejected(reasonCode);
114
159
  });
115
160
  } catch (error) {
116
161
  return rejected(error?.code === "ETIMEDOUT" ? "handshake_timeout" : "handshake_failed");
@@ -125,12 +170,12 @@ export async function offerMessage({ binding, message, runtimeDir, timeoutMs = 5
125
170
  if (endpoint === null) return rejected("recipient_unavailable");
126
171
  try {
127
172
  return await usingPeer(endpoint.socketPath, timeoutMs, open, async peer => {
128
- const reason = await verifyReceiver(peer, endpoint);
129
- if (reason !== null) return rejected(reason === "handshake_timeout" ? "transport_error"
130
- : reason === "handshake_version_mismatch" ? "unsupported_client_version" : "recipient_unavailable");
173
+ const { reasonCode, servingVersion } = await verifyReceiver(peer, endpoint);
174
+ if (reasonCode !== null) return rejected(reasonCode === "handshake_timeout" ? "transport_error"
175
+ : reasonCode === "handshake_version_mismatch" ? "unsupported_client_version" : "recipient_unavailable");
131
176
  await addCodexQueueMessage(peer, { threadId: endpoint.threadId,
132
177
  messageId: message.messageId, text: renderText(message) });
133
- return { accepted: true, transport: "codex-app-server", clientVersion: endpoint.clientVersion };
178
+ return { accepted: true, transport: "codex-app-server", clientVersion: servingVersion };
134
179
  });
135
180
  } catch (error) {
136
181
  if (["EPERM", "EACCES"].includes(error?.code)) return rejected("transport_permission_denied");
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
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.5.0",
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.5.0",
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.5.0",
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.5.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -3,7 +3,7 @@
3
3
  export { CAPABILITY_SHAPE, assertCapabilities, defineAdapter } from "./capabilities.mjs";
4
4
  export { effectiveCapabilities, validateCertification } from "./certification.mjs";
5
5
  export { NATIVE_ACTIVATION_KINDS, NATIVE_BINDING_MODES, NATIVE_PLATFORMS, NATIVE_REASON_CODES,
6
- compareStableVersions, evaluateNativeEligibility, parseStableVersion,
6
+ compareStableVersions, evaluateNativeEligibility, evaluateVersionContract, parseStableVersion,
7
7
  validateNativeActivationPlan, validateNativeDeliveryContract, validateNativeHandshake }
8
8
  from "./native-delivery.mjs";
9
9
  export { EVENT_KINDS, NORMALIZED_EVENT_KEYS, normalizedEvent } from "./events.mjs";