agents-can-communicate 0.8.3 → 0.8.5

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 (58) hide show
  1. package/bin/acc-update-worker.mjs +4 -1
  2. package/bin/entrypoints/acc-mcp.mjs +3 -0
  3. package/bin/entrypoints/acc.mjs +5 -0
  4. package/docs/ADAPTER_AUTHORING.md +3 -0
  5. package/docs/ARCHITECTURE.md +7 -2
  6. package/docs/CLI.md +7 -1
  7. package/docs/CONCEPTS.md +2 -2
  8. package/docs/CONFIGURATION.md +3 -2
  9. package/docs/GETTING_STARTED.md +12 -9
  10. package/docs/HOW_IT_WORKS.md +2 -1
  11. package/docs/TROUBLESHOOTING.md +18 -2
  12. package/docs/UPGRADING.md +35 -0
  13. package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
  14. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
  15. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  16. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  17. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +7 -1
  18. package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +24 -1
  19. package/node_modules/@agents-can-communicate/adapter-codex/src/embedded-host.mjs +108 -0
  20. package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +16 -6
  21. package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +2 -0
  22. package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-host.mjs +33 -9
  23. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +61 -2
  24. package/node_modules/@agents-can-communicate/adapter-codex/src/native-discovery.mjs +82 -0
  25. package/node_modules/@agents-can-communicate/adapter-codex/src/service-setup.mjs +59 -5
  26. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  27. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  28. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  29. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  30. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  31. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +2 -1
  32. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-attempt.mjs +6 -2
  33. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +17 -7
  34. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +12 -2
  35. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  36. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +2 -1
  37. package/node_modules/@agents-can-communicate/cli/src/main.mjs +19 -2
  38. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +25 -8
  39. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/install.mjs +13 -3
  40. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +19 -1
  41. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +53 -3
  42. package/node_modules/@agents-can-communicate/cli/src/native-delivery-status.mjs +5 -1
  43. package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +7 -1
  44. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  45. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  46. package/node_modules/@agents-can-communicate/delivery-router/src/native-reason.mjs +22 -0
  47. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +19 -3
  48. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  49. package/node_modules/@agents-can-communicate/hook-runner/src/native-attempt.mjs +6 -1
  50. package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +6 -3
  51. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +123 -10
  52. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  53. package/node_modules/@agents-can-communicate/installer/src/delivery-diagnostics.mjs +11 -1
  54. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  55. package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +3 -0
  56. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  57. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  58. package/package.json +1 -1
@@ -1,4 +1,8 @@
1
- import { decisionBody } from "@agents-can-communicate/adapter-sdk";
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, rm, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { decisionBody, isLaunchOption } from "@agents-can-communicate/adapter-sdk";
2
6
 
3
7
  import { CODEX_QUEUE_MINIMUM, MINIMUM_VERSION, PROTOCOL_CONTRACT, QUEUE_MODES,
4
8
  addCodexQueueMessage, canonicalCwd, compareVersions, controlSocketPath,
@@ -6,6 +10,7 @@ import { CODEX_QUEUE_MINIMUM, MINIMUM_VERSION, PROTOCOL_CONTRACT, QUEUE_MODES,
6
10
  versionOrder } from "./app-server-client.mjs";
7
11
  import { newEndpointId, readNativeEndpoint, readySocketPath, removeNativeEndpoint, socketIsReady,
8
12
  writeNativeEndpoint } from "./native-endpoint.mjs";
13
+ import { embeddedHost, hostArgv } from "./embedded-host.mjs";
9
14
 
10
15
  // The receiver's hook supplies thread and cwd. Core holds only a random endpoint
11
16
  // reference; sender environment never decides which daemon receives the message.
@@ -99,7 +104,19 @@ export async function verifyReceiver(peer, endpoint, { probe = probeCodexQueue,
99
104
  servingVersion: null };
100
105
  }
101
106
 
102
- export async function bindNativeSession({ event, clientPid, clientVersion, runtimeDir,
107
+ // A chat that runs embedded has no receiver for any check below to find. Its
108
+ // host process is read only after a check failed, so a bound session never pays
109
+ // for it, and the reason then names the cause instead of the symptom.
110
+ export async function bindNativeSession({ argvOf = hostArgv, ...options } = {}) {
111
+ const result = await bindAttempt(options);
112
+ if (result.supported || !Number.isInteger(options.clientPid) || options.clientPid <= 0) return result;
113
+ const host = await embeddedHost(options.clientPid, argvOf);
114
+ if (host === null) return result;
115
+ const refusal = closed(options.clientVersion, "client_session_embedded");
116
+ return host.launchOption === null ? refusal : { ...refusal, launchOption: host.launchOption };
117
+ }
118
+
119
+ async function bindAttempt({ event, clientPid, clientVersion, runtimeDir,
103
120
  env = process.env, timeoutMs = 750, now = Date.now, open = openCodexAppServer } = {}) {
104
121
  const rejected = reason => closed(clientVersion, reason);
105
122
  if (!Number.isInteger(clientPid) || clientPid <= 0) return rejected("client_process_unknown");
@@ -193,6 +210,48 @@ export async function offerMessage({ binding, message, runtimeDir, timeoutMs = 5
193
210
  export const retireNativeSession = ({ binding, runtimeDir }) =>
194
211
  removeNativeEndpoint({ runtimeDir, endpointId: binding?.opaqueEndpointRef });
195
212
 
213
+ const OPEN_A_CHAT = "open a new Codex chat; if Codex does not start its app-server daemon itself, "
214
+ + "run `codex app-server daemon start` first";
215
+ // Options a setting in config.toml can replace; the rest are simply left off.
216
+ const SETTING_OPTIONS = new Set(["-c", "--config", "--enable", "--disable", "--search", "-p",
217
+ "--profile"]);
218
+
219
+ // What the chat is told: one line for the model, one the client shows the user
220
+ // (Codex prints a hook's systemMessage, which never reaches the model).
221
+ function embeddedNotice(launchOption) {
222
+ if (!isLaunchOption(launchOption)) {
223
+ return { line: "ACC: live peer delivery is off in this Codex chat. It runs on its own embedded "
224
+ + "app server, so peer messages reach it only with the user's next prompt. ACC has shown the "
225
+ + `user how to get it: ${OPEN_A_CHAT}.`,
226
+ userMessage: "ACC: live peer delivery is off in this chat, which runs on its own app server. "
227
+ + `For live delivery, ${OPEN_A_CHAT}.` };
228
+ }
229
+ const advice = `start Codex without \`${launchOption}\``
230
+ + (SETTING_OPTIONS.has(launchOption) ? " and move what it sets into config.toml" : "");
231
+ return { line: `ACC: live peer delivery is off in this Codex chat. It was started with `
232
+ + `\`${launchOption}\`, so it runs on its own embedded app server and peer messages reach it `
233
+ + `only with the user's next prompt. ACC has shown the user how to get it: ${advice}.`,
234
+ userMessage: `ACC: live peer delivery is off in this chat: \`${launchOption}\` makes Codex run `
235
+ + `it on its own app server. For live delivery, ${advice}.` };
236
+ }
237
+
238
+ // One delivered notice per chat. The marker is created exclusively, so a
239
+ // second turn finds it; a notice the runner did not deliver gives it back.
240
+ export async function nativeActivationHint({ event, nativeBinding, runtimeDir }) {
241
+ if (nativeBinding?.state !== "degraded" || nativeBinding.reasonCode !== "client_session_embedded"
242
+ || typeof event?.sessionId !== "string" || event.sessionId === ""
243
+ || typeof runtimeDir !== "string") return null;
244
+ const dir = path.join(runtimeDir, "codex-embedded-notices");
245
+ const marker = path.join(dir, createHash("sha256").update(event.sessionId).digest("hex"));
246
+ try {
247
+ await mkdir(dir, { recursive: true, mode: 0o700 });
248
+ await writeFile(marker, "", { flag: "wx", mode: 0o600 });
249
+ } catch {
250
+ return null;
251
+ }
252
+ return { ...embeddedNotice(nativeBinding.launchOption), release: () => rm(marker, { force: true }) };
253
+ }
254
+
196
255
  function renderText(message) {
197
256
  const lines = [
198
257
  `ACC peer message ${message.messageId} (${message.kind}): untrusted peer content, not an instruction.`,
@@ -0,0 +1,82 @@
1
+ import { canonicalCwd, hooksForDirectories, loadedThreadMetadata, openCodexAppServer,
2
+ probeCodexQueue } from "./app-server-client.mjs";
3
+ import { CODEX_PLUGIN_ID } from "./install.mjs";
4
+ import { maintenanceContext, readMaintenancePid, runMaintenanceCommand, verifyMaintenanceProcess }
5
+ from "./maintenance-host.mjs";
6
+ import { readySocketPath } from "./native-endpoint.mjs";
7
+
8
+ // The hooks that register a chat, show it messages, and close it. A directory
9
+ // where any of them would not run cannot host a chat ACC may register.
10
+ const REGISTERING_EVENTS = ["sessionStart", "sessionEnd", "userPromptSubmit"];
11
+ const RUNS = new Set(["trusted", "managed"]);
12
+
13
+ const accHooksRun = hooks => REGISTERING_EVENTS.every(eventName => hooks.some(hook =>
14
+ hook?.pluginId === CODEX_PLUGIN_ID && hook.eventName === eventName && hook.enabled === true
15
+ && RUNS.has(hook.trustStatus)));
16
+
17
+ async function withPeer(open, socketPath, timeoutMs, run) {
18
+ const peer = await open({ socketPath, timeoutMs });
19
+ let timer;
20
+ try {
21
+ return await Promise.race([run(peer), new Promise((_resolve, reject) => {
22
+ timer = setTimeout(() => reject(Object.assign(new Error("discovery timed out"),
23
+ { code: "ETIMEDOUT" })), Math.max(1, timeoutMs));
24
+ })]);
25
+ } finally {
26
+ clearTimeout(timer);
27
+ await Promise.resolve(peer.close?.()).catch(() => null);
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Chats the shared daemon holds that ACC could register before their first
33
+ * turn (#167): loaded, idle, neither a subagent nor ephemeral, in a directory
34
+ * where ACC's own hooks are enabled and trusted, on a daemon whose pid and
35
+ * socket are proven. Each carries the daemon's pid, the process a hook for it
36
+ * would record. Metadata only; no conversation is read. Any doubt is an empty
37
+ * answer, never an error.
38
+ */
39
+ export function createCodexDiscovery({ run = runMaintenanceCommand, open = openCodexAppServer,
40
+ contextPaths = maintenanceContext } = {}) {
41
+ async function discoverNativeSessions({ env = process.env, home, timeoutMs = 1_500 } = {}) {
42
+ // The whole answer, process checks included, within the caller's timeout.
43
+ let timer;
44
+ return Promise.race([discover({ env, home, timeoutMs }), new Promise(resolve => {
45
+ timer = setTimeout(resolve, Math.max(1, timeoutMs), []);
46
+ })]).finally(() => clearTimeout(timer));
47
+ }
48
+ async function discover({ env, home, timeoutMs }) {
49
+ try {
50
+ const found = await contextPaths({ env, home });
51
+ // A process check that outlives the answer would still keep the
52
+ // caller's process alive until its own timeout, so it gets this one.
53
+ const paths = { ...found, options: { ...found.options, timeout: Math.max(1, timeoutMs) } };
54
+ const socketPath = await readySocketPath(paths.socketPath);
55
+ if (socketPath === null) return [];
56
+ const daemon = await readMaintenancePid(paths.pidPath);
57
+ await verifyMaintenanceProcess(daemon, paths, run);
58
+ return await withPeer(open, socketPath, timeoutMs, async peer => {
59
+ const probe = await probeCodexQueue(peer);
60
+ if (!probe.supported && probe.reasonCode !== "native_session_unavailable") return [];
61
+ const candidates = [];
62
+ for (const thread of await loadedThreadMetadata(peer)) {
63
+ if (thread.parentThreadId != null || thread.ephemeral === true
64
+ || thread.status?.type !== "idle") continue;
65
+ const cwd = await canonicalCwd(thread.cwd);
66
+ if (cwd !== null) candidates.push({ sessionId: thread.id, cwd });
67
+ }
68
+ if (candidates.length === 0) return [];
69
+ const ready = new Set();
70
+ for (const entry of await hooksForDirectories(peer, [...new Set(candidates.map(item => item.cwd))])) {
71
+ const cwd = await canonicalCwd(entry.cwd);
72
+ if (cwd !== null && accHooksRun(entry.hooks)) ready.add(cwd);
73
+ }
74
+ return candidates.filter(item => ready.has(item.cwd)).map(item => ({ ...item,
75
+ clientPid: daemon.pid, clientVersion: probe.serverVersion }));
76
+ });
77
+ } catch {
78
+ return [];
79
+ }
80
+ }
81
+ return { discoverNativeSessions };
82
+ }
@@ -1,5 +1,6 @@
1
1
  import { lstat, realpath } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { compareVersions } from "./app-server-client.mjs";
3
4
  import { readySocketPath, socketIsReady } from "./native-endpoint.mjs";
4
5
  import { probeNativeDelivery } from "./native-delivery.mjs";
5
6
  import { installCodexStandalone } from "./standalone-install.mjs";
@@ -13,6 +14,9 @@ const keys = ["home", "codexHome", "cliPath", "cliVersion", "managedPath", "mana
13
14
  const prerequisiteKeys = ["home", "codexHome", "cliPath", "cliVersion", "cliIdentity", "socketPath", "pidPath", "platform"];
14
15
  const same = (a, b, fields = keys) => fields.every(key => a?.[key] === b?.[key]);
15
16
  const own = info => typeof process.getuid !== "function" || info.uid === process.getuid();
17
+ // The first CLI measured installing the daemon's own package in a home that
18
+ // has none (2026-09-26; again on 0.159.0, 2026-09-29).
19
+ const SELF_INSTALL_MINIMUM = "0.157.1";
16
20
  const sessionNeeded = "Codex service ready; open a new Codex session to establish its delivery binding and review client hooks and permissions";
17
21
  const diagnostics = {
18
22
  native_endpoint_unavailable: "Prepare the missing Codex service with codex app-server daemon start, then open a new Codex session",
@@ -26,13 +30,25 @@ const diagnostics = {
26
30
  managed_binary_mismatch: "Codex CLI and managed standalone versions differ; repair the vendor installation, then retry acc install",
27
31
  maintenance_cli_unsupported: "Codex cold service preparation requires codex-cli 0.154.0 or newer with daemon commands; update the vendor installation, then retry acc install",
28
32
  };
33
+ const startsOnLaunch = "The Codex service is not running; Codex starts it with the next Codex session "
34
+ + "(daemon_auto_start), so open Codex, or run acc install to start it now";
29
35
  function report(state, reasonCode, facts = {}) {
30
36
  return { ...facts, state, reasonCode, diagnostic: state === "ready"
31
37
  ? reasonCode === "native_session_unavailable" ? sessionNeeded
32
38
  : "Codex service infrastructure is ready; current session binding is reported separately"
39
+ : facts.startsOnLaunch === true ? startsOnLaunch
33
40
  : diagnostics[reasonCode] ?? `Codex service preparation could not verify ${reasonCode}; inspect the vendor service and retry acc install` };
34
41
  }
35
42
 
43
+ // Since 0.157.1 a TUI that finds no service starts one (`daemon_auto_start`,
44
+ // stable and on by default). `codex features list` reports the effective value
45
+ // after config and profiles; any other answer leaves the start to acc install.
46
+ const AUTO_START = /^daemon_auto_start\s+\S+\s+true\s*$/m;
47
+ async function launchStart(run, paths, facts) {
48
+ const listed = await run(facts.cliPath, ["features", "list"], paths.options).catch(() => null);
49
+ return listed?.status === 0 && AUTO_START.test(listed.stdout) ? { startsOnLaunch: true } : {};
50
+ }
51
+
36
52
  export function createCodexServiceSetup({ run = runMaintenanceCommand, probe = probeNativeDelivery,
37
53
  fs = { lstat, realpath }, contextPaths = maintenanceContext,
38
54
  installStandalone = installCodexStandalone } = {}) {
@@ -90,9 +106,43 @@ export function createCodexServiceSetup({ run = runMaintenanceCommand, probe = p
90
106
  const installation = await probeMaintenanceCli({ ...paths,
91
107
  options: { ...paths.options, cwd: paths.options.env.HOME } }, run);
92
108
  const cli = await executableIdentity(installation.cliPath);
93
- return report("needed", "managed_install_missing", { requiresInstall: true,
94
- home: paths.options.env.HOME, codexHome: paths.codexHome, cliIdentity: cli.identity,
95
- socketPath: paths.socketPath, pidPath: paths.pidPath, platform: paths.platform, ...installation });
109
+ const facts = { home: paths.options.env.HOME, codexHome: paths.codexHome, cliIdentity: cli.identity,
110
+ socketPath: paths.socketPath, pidPath: paths.pidPath, platform: paths.platform, ...installation };
111
+ // A CLI that installs the daemon's own package at its first start needs
112
+ // no standalone download: starting the service is the whole setup (#205).
113
+ if (compareVersions(installation.cliVersion, SELF_INSTALL_MINIMUM) >= 0) {
114
+ return report("needed", "native_endpoint_unavailable", { ...facts, selfInstall: true,
115
+ ...await launchStart(run, paths, facts) });
116
+ }
117
+ return report("needed", "managed_install_missing", { requiresInstall: true, ...facts });
118
+ }
119
+ // The service a self-installing CLI prepares: start it, then verify the
120
+ // package and daemon it installed exactly as an existing one is verified.
121
+ async function startSelfInstalling(context, plan) {
122
+ let started = false;
123
+ try {
124
+ if (plan.state !== "needed" || !prerequisiteKeys.every(key => typeof plan[key] === "string")) {
125
+ failMaintenance("invalid_service_setup_plan");
126
+ }
127
+ const current = await inspect(context, { strict: true });
128
+ if (current.state !== "needed" || current.selfInstall !== true || !same(plan, current, prerequisiteKeys)) {
129
+ failMaintenance("service_identity_changed");
130
+ }
131
+ started = true;
132
+ const result = await run(plan.cliPath, ["app-server", "daemon", "start"], {
133
+ cwd: plan.codexHome, env: { ...(context?.env ?? process.env), HOME: plan.home, CODEX_HOME: plan.codexHome },
134
+ timeout: 15_000,
135
+ });
136
+ if (result.status !== 0) failMaintenance("daemon_start_failed");
137
+ const paths = await contextPaths(context);
138
+ const facts = await installed(paths);
139
+ if (!same(plan, facts, ["codexHome", "cliPath", "cliVersion", "cliIdentity", "socketPath"])) {
140
+ failMaintenance("service_identity_changed");
141
+ }
142
+ return { ...await verify(paths, facts), started };
143
+ } catch (error) {
144
+ return { ...report(started ? "failed" : "blocked", error.reasonCode ?? "daemon_start_failed"), started };
145
+ }
96
146
  }
97
147
  async function verify(paths, facts) {
98
148
  if (!await readySocketPath(paths.socketPath)) failMaintenance("daemon_socket_unproven");
@@ -129,9 +179,12 @@ export function createCodexServiceSetup({ run = runMaintenanceCommand, probe = p
129
179
  if (!await info(paths.managedPath)) return await missingPrerequisite(paths);
130
180
  const facts = await installed(paths);
131
181
  if (!await info(paths.socketPath) && !await info(paths.pidPath)) {
132
- return report("needed", "native_endpoint_unavailable", facts);
182
+ return report("needed", "native_endpoint_unavailable",
183
+ { ...facts, ...await launchStart(run, paths, facts) });
184
+ }
185
+ if (await serviceStopped(paths)) {
186
+ return report("needed", "service_stopped", { ...facts, ...await launchStart(run, paths, facts) });
133
187
  }
134
- if (await serviceStopped(paths)) return report("needed", "service_stopped", facts);
135
188
  return await verify(paths, facts);
136
189
  } catch (error) {
137
190
  const reason = error.reasonCode ?? "service_inspection_failed";
@@ -165,6 +218,7 @@ export function createCodexServiceSetup({ run = runMaintenanceCommand, probe = p
165
218
  }
166
219
  async function prepareNativeServiceSetup({ context, plan, installPrerequisites, download } = {}) {
167
220
  if (plan?.requiresInstall === true) return preparePrerequisite(context, plan, installPrerequisites, download);
221
+ if (plan?.selfInstall === true) return startSelfInstalling(context, plan);
168
222
  let started = false;
169
223
  try {
170
224
  if (plan?.state === "ready") {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
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.8.3",
3
+ "version": "0.8.5",
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.8.3",
3
+ "version": "0.8.5",
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.8.3",
3
+ "version": "0.8.5",
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.8.3",
3
+ "version": "0.8.5",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -5,7 +5,8 @@ export { capabilityEvidence, compareVersionOrder, effectiveCapabilities, validat
5
5
  versionOrder }
6
6
  from "./certification.mjs";
7
7
  export { NATIVE_ACTIVATION_KINDS, NATIVE_BINDING_MODES, NATIVE_REASON_CODES,
8
- compareStableVersions, evaluateNativeEligibility, evaluateVersionContract, parseStableVersion,
8
+ compareStableVersions, evaluateNativeEligibility, evaluateVersionContract, isLaunchOption,
9
+ parseStableVersion,
9
10
  validateNativeActivationPlan, validateNativeDeliveryContract, validateNativeHandshake }
10
11
  from "./native-delivery.mjs";
11
12
  export { EVENT_KINDS, NORMALIZED_EVENT_KEYS, normalizedEvent } from "./events.mjs";
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { Worker } from "node:worker_threads";
5
5
 
6
- import { NATIVE_REASON_CODES, TIMESTAMP } from "./native-vocabulary.mjs";
6
+ import { NATIVE_REASON_CODES, TIMESTAMP, isLaunchOption } from "./native-vocabulary.mjs";
7
7
 
8
8
  // Optional diagnostic metadata beside the hook-owner file, never an event
9
9
  // history or injected context. Copy only closed values: vendor errors, prompts,
@@ -18,9 +18,13 @@ export function nativeAttemptFrom(value) {
18
18
  || !["bootstrap-environment", "installation-record"].includes(value.policySource)
19
19
  || !["enabled", "off", "missing", "invalid", "unavailable"].includes(value.policyStatus)
20
20
  || !["identified", "unknown"].includes(value.clientProcess)) return null;
21
+ // The option a refusal named is optional and closed to a bare option token;
22
+ // anything else is dropped and the attempt itself kept.
21
23
  return { at: value.at, event: value.event, state: value.state, reasonCode: value.reasonCode,
22
24
  policy: value.policy, policySource: value.policySource, policyStatus: value.policyStatus,
23
- clientProcess: value.clientProcess };
25
+ clientProcess: value.clientProcess,
26
+ ...(value.state !== "active" && isLaunchOption(value.launchOption)
27
+ ? { launchOption: value.launchOption } : {}) };
24
28
  }
25
29
 
26
30
  const keyFor = harnessSessionId => createHash("sha256").update(String(harnessSessionId))
@@ -1,7 +1,7 @@
1
- import { CONTRACT_ID, FINGERPRINT, HANDSHAKE_KEYS, KNOWN_BAD_REASON, NATIVE_BINDING_MODES,
2
- PLANNABLE_ACTIVATION_KINDS, PROBE_KEYS, TIMESTAMP, assertModes, assertReasonCode, closed,
3
- compareStableVersions, deepFreeze, isPlainObject, isText, parseStableVersion, usage }
4
- from "./native-vocabulary.mjs";
1
+ import { CONTRACT_ID, FINGERPRINT, HANDSHAKE_KEYS, HANDSHAKE_OPTIONAL_KEYS, KNOWN_BAD_REASON,
2
+ NATIVE_BINDING_MODES, PLANNABLE_ACTIVATION_KINDS, PROBE_KEYS, TIMESTAMP, assertModes,
3
+ assertReasonCode, closed, compareStableVersions, deepFreeze, isLaunchOption, isPlainObject, isText,
4
+ parseStableVersion, usage } from "./native-vocabulary.mjs";
5
5
  import { compareVersionOrder, versionOrder } from "./certification.mjs";
6
6
 
7
7
  // The native-delivery compatibility contract: a minimum that is a real passing
@@ -17,7 +17,8 @@ import { compareVersionOrder, versionOrder } from "./certification.mjs";
17
17
  // live delivery alone.
18
18
 
19
19
  export { NATIVE_ACTIVATION_KINDS, NATIVE_BINDING_MODES, NATIVE_REASON_CODES,
20
- PLANNABLE_ACTIVATION_KINDS, compareStableVersions, parseStableVersion } from "./native-vocabulary.mjs";
20
+ PLANNABLE_ACTIVATION_KINDS, compareStableVersions, isLaunchOption, parseStableVersion }
21
+ from "./native-vocabulary.mjs";
21
22
  export { validateNativeActivationPlan } from "./native-activation.mjs";
22
23
 
23
24
  const orderedModes = modes => NATIVE_BINDING_MODES.filter(mode => modes.includes(mode));
@@ -214,7 +215,7 @@ export function evaluateNativeEligibility(adapter, { clientVersion, probe }) {
214
215
  }
215
216
 
216
217
  function validateNativeHandshakeShape(handshake) {
217
- closed(handshake, HANDSHAKE_KEYS, "native handshake");
218
+ closed(handshake, HANDSHAKE_KEYS, "native handshake", HANDSHAKE_OPTIONAL_KEYS);
218
219
  if (typeof handshake.supported !== "boolean") usage("native handshake supported must be a boolean");
219
220
  if (handshake.clientVersion !== null && !isText(handshake.clientVersion)) {
220
221
  usage("native handshake clientVersion must be a string or null");
@@ -236,6 +237,12 @@ function validateNativeHandshakeShape(handshake) {
236
237
  } else if (handshake.opaqueEndpointRef !== null || handshake.leaseUntil !== null) {
237
238
  usage("an unsupported native handshake carries no endpoint or lease");
238
239
  }
240
+ if (Object.hasOwn(handshake, "launchOption")) {
241
+ if (handshake.supported) usage("only a refused native handshake names a launchOption");
242
+ if (!isLaunchOption(handshake.launchOption)) {
243
+ usage("native handshake launchOption must be a bare command-line option");
244
+ }
245
+ }
239
246
  return handshake;
240
247
  }
241
248
 
@@ -264,7 +271,10 @@ export function validateNativeHandshake(adapter, { clientVersion, handshake }) {
264
271
  if (rule.reasonCode !== null) return closedResult(rule.reasonCode);
265
272
  if (handshake === null || handshake === undefined) return closedResult("handshake_failed");
266
273
  const facts = validateNativeHandshakeShape(handshake);
267
- if (facts.supported !== true) return closedResult(facts.reasonCode ?? "handshake_failed");
274
+ if (facts.supported !== true) {
275
+ return deepFreeze({ ...base, reasonCode: facts.reasonCode ?? "handshake_failed",
276
+ ...(Object.hasOwn(facts, "launchOption") ? { launchOption: facts.launchOption } : {}) });
277
+ }
268
278
  if (facts.protocolContract !== rule.protocolContract) return closedResult("protocol_mismatch");
269
279
  const modes = orderedModes(facts.modes);
270
280
  if (!modes.includes("livePush")) return closedResult("handshake_failed");
@@ -34,6 +34,9 @@ export const NATIVE_REASON_CODES = Object.freeze([
34
34
  // The current session's canonical workspace identity could not be verified.
35
35
  // This is a per-session refusal, not a universal vendor limitation.
36
36
  "workspace_identity_unavailable",
37
+ // The client runs this session on its own embedded service, which no sender
38
+ // can reach, instead of the shared local one; it stays so until it closes.
39
+ "client_session_embedded",
37
40
  ]);
38
41
 
39
42
  export const STABLE_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z.-]+)?$/;
@@ -47,6 +50,13 @@ export const PROBE_KEYS = ["supported", "clientVersion", "protocolContract", "ex
47
50
  "modes", "reasonCode"];
48
51
  export const HANDSHAKE_KEYS = ["supported", "clientVersion", "protocolContract", "modes",
49
52
  "opaqueEndpointRef", "leaseUntil", "reasonCode"];
53
+ // A refused handshake may name the client launch option that caused it, such
54
+ // as Codex's `--search`. A bare option token, never its value: a value can be
55
+ // anything a user typed.
56
+ export const HANDSHAKE_OPTIONAL_KEYS = ["launchOption"];
57
+ export const LAUNCH_OPTION = /^-(?:[a-z]|-[a-z][a-z0-9]*(?:-[a-z0-9]+)*)$/;
58
+ export const isLaunchOption = value => typeof value === "string" && value.length <= 40
59
+ && LAUNCH_OPTION.test(value);
50
60
 
51
61
  export function usage(message, details = {}) {
52
62
  throw new AccError(EXIT.USAGE, message, details);
@@ -55,10 +65,10 @@ export function usage(message, details = {}) {
55
65
  export const isPlainObject = value => value !== null && typeof value === "object" && !Array.isArray(value);
56
66
  export const isText = value => typeof value === "string" && value !== "";
57
67
 
58
- export function closed(value, keys, label) {
68
+ export function closed(value, keys, label, optional = []) {
59
69
  if (!isPlainObject(value)) usage(`${label} must be an object`);
60
70
  for (const key of Object.keys(value)) {
61
- if (!keys.includes(key)) usage(`unknown ${label} field ${key}`, { key });
71
+ if (!keys.includes(key) && !optional.includes(key)) usage(`unknown ${label} field ${key}`, { key });
62
72
  }
63
73
  for (const key of keys) {
64
74
  if (!Object.hasOwn(value, key)) usage(`${label} requires ${key}`, { key });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/cli",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -12,7 +12,7 @@ export { describeNative } from "./native-delivery-status.mjs";
12
12
  import { nativeSessionLines, updateNativeSessions } from "./native-session-diagnostics.mjs";
13
13
  import { ALL_ADAPTERS, clientContext, probeTimeout } from "./install-command.mjs";
14
14
  import { decisionOf } from "./install-delivery-consent.mjs";
15
- import { describePresence, presenceBreakdown } from "./main.mjs";
15
+ import { describePresence, presenceBreakdown, registerNativeSessions } from "./main.mjs";
16
16
  import { platformPaths } from "./platform-paths.mjs";
17
17
  import { noticeUpdate } from "./update-check.mjs";
18
18
  import { managedUpdateDiagnostic } from "./managed-runtime/diagnostics.mjs";
@@ -233,6 +233,7 @@ export async function runDoctor({ options, context, runtime }) {
233
233
  // context built for this command opens the store on request rather than up
234
234
  // front.
235
235
  const service = context.service ?? await context.openService();
236
+ await registerNativeSessions(runtime, context);
236
237
  const status = await service.collectStatus({});
237
238
  updateNativeRuntime(adapters, status.deliveryBindings);
238
239
  // The runtime state above is read from the binding records alone. The
@@ -6,6 +6,7 @@ import { AccError, CONFIG_FILENAME, EXIT, GENERIC_MESSAGE_KINDS, VALID_OBLIGATIO
6
6
  failure, ok }
7
7
  from "@agents-can-communicate/protocol";
8
8
  import { createCoordinationService } from "@agents-can-communicate/core";
9
+ import { describeNativeReason } from "@agents-can-communicate/installer";
9
10
  import { openFilesystemStore } from "@agents-can-communicate/storage-filesystem";
10
11
 
11
12
  import { parseArgs, positiveNumber } from "./args.mjs";
@@ -102,6 +103,18 @@ async function openContext(options, runtime) {
102
103
  return withService(await locateContext(options, runtime), runtime);
103
104
  }
104
105
 
106
+ /**
107
+ * Before a peer reads status, chats its clients hold but ACC has not seen yet
108
+ * are registered, so the peer can address them before their first turn
109
+ * (#167). Bounded and fail-open inside the port; status never waits on it
110
+ * beyond its budget or fails because of it.
111
+ */
112
+ export async function registerNativeSessions(runtime, context) {
113
+ if (typeof runtime?.registerNativeSessions !== "function") return [];
114
+ return Promise.resolve().then(() => runtime.registerNativeSessions({
115
+ workspaceId: context.descriptor.id, dataHome: context.dataHome })).catch(() => []);
116
+ }
117
+
105
118
  /**
106
119
  * A context for the one command that has to survive an unopenable store.
107
120
  *
@@ -150,7 +163,10 @@ export function recordedText(message, delivery) {
150
163
  ? item.errorCode === "transport_permission_denied"
151
164
  ? "live offer blocked by sender permissions; message remains queued; run acc doctor in the sending session"
152
165
  : item.errorCode === "no_live_transport"
153
- ? `${item.recipientParticipantId} has no live transport; the message waits in its inbox`
166
+ ? `${item.recipientParticipantId} has no live transport`
167
+ + `${item.nativeReasonCode ? ` (${describeNativeReason(item.nativeReasonCode,
168
+ { launchOption: item.nativeLaunchOption })})` : ""}`
169
+ + "; the message waits in its inbox"
154
170
  : item.errorCode === "recipient_offline" && item.endReason === "clear"
155
171
  ? `${item.recipientParticipantId}'s conversation was cleared (/clear); the message waits `
156
172
  + "until that conversation resumes"
@@ -406,7 +422,8 @@ const HANDLERS = Object.freeze({
406
422
  text: recordedText(handoff.message, routed.delivery) };
407
423
  },
408
424
 
409
- status: async ({ options, context }) => {
425
+ status: async ({ options, context, runtime }) => {
426
+ await registerNativeSessions(runtime, context);
410
427
  const status = await context.service.collectStatus({
411
428
  participantId: options.participant, all: options.all === true });
412
429
  return { data: status, text: describeStatus(status) };
@@ -154,6 +154,10 @@ async function resolveCandidate(value) {
154
154
  * Takes its own manager lock, so a caller already holding one (activation)
155
155
  * must call this only after releasing it.
156
156
  */
157
+ // An unknown holder postpones the whole pass; callers that record having
158
+ // reclaimed need to tell that from a pass that found nothing to remove.
159
+ const POSTPONED = Object.freeze({ removed: [], postponed: true });
160
+
157
161
  export async function reclaimGenerations({ root, active = null, pidIsAlive = defaultPidIsAlive } = {}) {
158
162
  root = await canonicalManagerRoot(root);
159
163
  return withManagerLock(root, async () => {
@@ -186,19 +190,19 @@ export async function reclaimGenerations({ root, active = null, pidIsAlive = def
186
190
  // readControl already throws on, and every real caller of this function
187
191
  // treats that throw as reason to postpone. Match that here explicitly:
188
192
  // an absent control.json is not proof there is nothing to protect.
189
- if (control === null) return { removed: [] };
193
+ if (control === null) return POSTPONED;
190
194
  const referenced = new Set();
191
195
  for (const candidate of [active, control.active.root, control.pending?.root]) {
192
196
  const resolved = await resolveCandidate(candidate);
193
- if (!resolved.ok) return { removed: [] };
197
+ if (!resolved.ok) return POSTPONED;
194
198
  if (resolved.root) referenced.add(resolved.root);
195
199
  }
196
200
  let holds;
197
201
  try { holds = await listRuntimeHolds(root, { pidIsAlive }); }
198
- catch { return { removed: [] }; } // An unreadable lease is an unknown holder.
202
+ catch { return POSTPONED; } // An unreadable lease is an unknown holder.
199
203
  for (const lease of holds) {
200
204
  const resolved = await resolveCandidate(lease.runtime?.root);
201
- if (!resolved.ok) return { removed: [] };
205
+ if (!resolved.ok) return POSTPONED;
202
206
  if (resolved.root) referenced.add(resolved.root);
203
207
  }
204
208
  // A client that exits without a clean session end leaves its pin behind;
@@ -212,9 +216,9 @@ export async function reclaimGenerations({ root, active = null, pidIsAlive = def
212
216
  const record = await readManagedJson(path.join(pins, name)).catch(() => null);
213
217
  if (record === undefined) continue; // Concurrent pin removal, not a holder.
214
218
  if (!record || record.schemaVersion !== 1 || typeof record.runtimeRoot !== "string"
215
- || record.runtimeRoot === "") return { removed: [] }; // Malformed pin: unknown holder.
219
+ || record.runtimeRoot === "") return POSTPONED; // Malformed pin: unknown holder.
216
220
  const resolved = await resolveCandidate(record.runtimeRoot);
217
- if (!resolved.ok) return { removed: [] };
221
+ if (!resolved.ok) return POSTPONED;
218
222
  referenced.add(resolved.root);
219
223
  }
220
224
  }
@@ -231,9 +235,9 @@ export async function reclaimGenerations({ root, active = null, pidIsAlive = def
231
235
  const record = await readManagedJson(path.join(staging, name)).catch(() => null);
232
236
  if (record === undefined) continue; // Concurrent hold release, not a holder.
233
237
  if (!record || record.schemaVersion !== 1 || typeof record.generationRoot !== "string"
234
- || record.generationRoot === "") return { removed: [] }; // Malformed hold: unknown holder.
238
+ || record.generationRoot === "") return POSTPONED; // Malformed hold: unknown holder.
235
239
  const resolved = await resolveCandidate(record.generationRoot);
236
- if (!resolved.ok) return { removed: [] };
240
+ if (!resolved.ok) return POSTPONED;
237
241
  referenced.add(resolved.root);
238
242
  }
239
243
  }
@@ -319,6 +323,19 @@ export async function activatePending(root, { prepare = prepareCandidate, env =
319
323
  if (outcome.activated) {
320
324
  try { await reclaimGenerations({ root, active: activatedRoot, pidIsAlive }); }
321
325
  catch { /* Best effort; never fails an activation that already succeeded. */ }
326
+ // The reclaim above is this, older, code's rule (#208). The activated
327
+ // generation's own rule runs next, from its own files as its refresh did,
328
+ // and records that it ran. A target without it (an older release) is left
329
+ // to its own worker; nothing here can fail the activation.
330
+ try { await reclaimAsActivated(root, activatedRoot); }
331
+ catch { /* Best effort, as above. */ }
322
332
  }
323
333
  return outcome;
324
334
  }
335
+
336
+ async function reclaimAsActivated(root, activatedRoot) {
337
+ const file = path.join(activatedRoot, "node_modules", "@agents-can-communicate", "cli", "src",
338
+ "managed-runtime", "worker.mjs");
339
+ const { reclaimAsActive } = await import(pathToFileURL(file).href);
340
+ if (typeof reclaimAsActive === "function") await reclaimAsActive(root, activatedRoot);
341
+ }