agents-can-communicate 0.8.4 → 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 (38) hide show
  1. package/docs/ADAPTER_AUTHORING.md +3 -0
  2. package/docs/ARCHITECTURE.md +7 -4
  3. package/docs/TROUBLESHOOTING.md +11 -4
  4. package/docs/UPGRADING.md +17 -0
  5. package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
  6. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
  7. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  8. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  9. package/node_modules/@agents-can-communicate/adapter-codex/src/embedded-host.mjs +47 -4
  10. package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +16 -6
  11. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +31 -9
  12. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  13. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  14. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  15. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  16. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  17. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +2 -1
  18. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-attempt.mjs +6 -2
  19. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +17 -7
  20. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +9 -2
  21. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  22. package/node_modules/@agents-can-communicate/cli/src/main.mjs +2 -1
  23. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +18 -3
  24. package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +4 -1
  25. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  26. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  27. package/node_modules/@agents-can-communicate/delivery-router/src/native-reason.mjs +6 -2
  28. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +9 -5
  29. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  30. package/node_modules/@agents-can-communicate/hook-runner/src/native-attempt.mjs +6 -1
  31. package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +6 -3
  32. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +13 -6
  33. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  34. package/node_modules/@agents-can-communicate/installer/src/delivery-diagnostics.mjs +9 -1
  35. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  36. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  37. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  38. package/package.json +1 -1
@@ -192,6 +192,9 @@ transport that is technically different on some platform is the probe's fact to
192
192
  `docs/design/2026-09-26-native-delivery-across-platforms.md` in the repository.
193
193
  - Native methods return closed facts (`validateNativeActivationPlan()` closes the
194
194
  activation plan) and never put vendor data - endpoints, sockets, raw errors - into core.
195
+ A refused handshake may add `launchOption`: the bare command-line option, such as
196
+ `--search`, that made the client refuse. Doctor and the sender name it; a value, a vendor
197
+ string or anything but an option token is rejected.
195
198
 
196
199
  `nativeDelivery.policySource` accepts only `"installation-record"`, which is also the
197
200
  default. Hooks and senders read the current recorded recipient consent, independently of
@@ -231,10 +231,13 @@ store contract this one does, and run the active generation otherwise. Apart fro
231
231
  `SessionEnd`, only the client's confirmed death releases a pin, so a session whose client
232
232
  process cannot be found is not pinned, and a pin that names no client is reaped. A
233
233
  generation directory is reclaimed once no control pointer, live lease, live pin or staging
234
- hold names it. An update is run by the installed code, so the reclaim after an activation
235
- follows the older rule; the new generation's own worker reclaims again on its first pass,
236
- recorded in `reclaim.json`, and on every later pass. Parsed help, version, and update recovery remain available when workspace
237
- admission is unavailable.
234
+ hold names it. An update is run by the installed code, so right after an activation the
235
+ activated generation's own reclaim runs from its files, and its worker reclaims on every later
236
+ pass; each pass is recorded in `reclaim.json`. The updating process still runs from the
237
+ generation it replaced, and its lease keeps that one through the pass. Such a pass, like one an
238
+ unknown holder postponed, is recorded as unfinished, and the next ACC command an hour or more
239
+ later starts the worker that finishes it. Parsed help, version, and update recovery remain
240
+ available when workspace admission is unavailable.
238
241
 
239
242
  An independent worker downloads npm packages with lifecycle scripts disabled, checks exact
240
243
  stable package identity and the discovered integrity, and health-checks the staged runtime.
@@ -139,10 +139,17 @@ client-owned hook and trust review.
139
139
  After a reboot no Codex service runs. Codex 0.157.1 and newer start it when the next Codex
140
140
  session opens (`daemon_auto_start`), and doctor then says so instead of advising `acc install`.
141
141
  A Codex chat that started without the service runs on its own embedded service until it closes,
142
- and no peer can reach it live. ACC reports it as `client_session_embedded`: the chat is told once,
143
- a sender sees the cause beside `no live transport`, and doctor names it on the session line.
144
- Open a new Codex chat while the service runs; start it with `codex app-server daemon start` if
145
- Codex does not.
142
+ and no peer can reach it live. ACC reports it as `client_session_embedded`: the chat shows the
143
+ user a notice once, a sender sees the cause beside `no live transport`, and doctor names it on
144
+ the session line. Open a new Codex chat while the service runs; start it with
145
+ `codex app-server daemon start` if Codex does not.
146
+
147
+ Some launch options make Codex run a chat embedded even while the service runs: `-c`/`--config`,
148
+ `--enable`, `--disable`, `--search`, `-p`/`--profile`, `--oss`, `--strict-config`,
149
+ `--dangerously-bypass-hook-trust` and `--no-daemon`, and also the `CODEX_EXEC_SERVER_URL`
150
+ environment variable. ACC names the option in the notice, the sender's line and doctor. For live
151
+ delivery, start Codex without it; put a setting you need in `config.toml` instead of `-c`,
152
+ `--enable`, `--disable`, `--search` or `--profile`.
146
153
 
147
154
  If a reply stays queued with `transport_permission_denied`, the sender's permissions
148
155
  blocked local transport. This is distinct from an unavailable recipient. The message was
package/docs/UPGRADING.md CHANGED
@@ -57,6 +57,23 @@ From 0.8.3, the download does this itself.
57
57
 
58
58
  This release adds no store event type or field.
59
59
 
60
+ 0.8.5 changes what a Codex chat that runs without the daemon is told:
61
+
62
+ - **The launch option that caused it is named.** Codex also runs a chat embedded, even while
63
+ its daemon runs, when it starts with `-c`/`--config`, `--enable`, `--disable`, `--search`,
64
+ `-p`/`--profile`, `--oss`, `--strict-config`, `--dangerously-bypass-hook-trust` or
65
+ `--no-daemon`. The notice, a sender's line and doctor name the option and advise starting
66
+ Codex without it; put a setting you need in `config.toml` instead.
67
+ - **You see the notice yourself.** Codex shows it once in the chat as a hook message
68
+ (`↳ Hook · ACC: …`); the model receives its own line, as before.
69
+ - **The previous runtime generation goes an hour after the update.** `acc update` runs from
70
+ the generation it replaces, so it cannot remove that one itself. It records the reclaim as
71
+ unfinished in `reclaim.json`, and the first ACC command an hour or more later removes the old
72
+ generation, with automatic updates on or off.
73
+
74
+ This release adds no store event type or field. A sender's delivery JSON may carry
75
+ `nativeLaunchOption` beside `nativeReasonCode`.
76
+
60
77
  ## From 0.7.x
61
78
 
62
79
  Claude Code live delivery wakes each session through the inbox socket that Claude Code opens
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-antigravity",
3
- "version": "0.8.4",
3
+ "version": "0.8.5",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "acc",
3
- "version": "0.8.4",
3
+ "version": "0.8.5",
4
4
  "description": "Coordinate this Antigravity CLI session with other AI agent sessions working in the same workspace."
5
5
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-claude-code",
3
- "version": "0.8.4",
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-codex",
3
- "version": "0.8.4",
3
+ "version": "0.8.5",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -55,11 +55,54 @@ export function interactiveHost(argv) {
55
55
  return true;
56
56
  }
57
57
 
58
- /** True only when the host was read and is an interactive TUI; never throws. */
59
- export async function runsEmbedded(pid, argvOf = hostArgv) {
58
+ // Options that make Codex run a chat on its own embedded app server even while
59
+ // the daemon runs. Measured on 0.159.1, which says so only behind its status
60
+ // line: "Running without the shared background server: command-line
61
+ // configuration overrides (-c, --enable, --disable, or --search) requires
62
+ // embedded mode." The same sentence names --profile, --oss, --strict-config,
63
+ // --dangerously-bypass-hook-trust and --no-daemon.
64
+ const EMBEDDING_OPTIONS = new Set(["-c", "--config", "--enable", "--disable", "--search", "-p",
65
+ "--profile", "--oss", "--strict-config", "--dangerously-bypass-hook-trust", "--no-daemon"]);
66
+ const OPENS_CHAT = new Set(["resume", "fork"]);
67
+
68
+ // `--config=x` and `-cx` carry their value; both name the option they spell.
69
+ const optionName = word => word.startsWith("--") ? word.split("=", 1)[0] : word.slice(0, 2);
70
+
71
+ /**
72
+ * The first option on this interactive command line that keeps the chat
73
+ * embedded, as typed, or null. Scanning stops at the prompt: `ps` drops quotes,
74
+ * so a later word may be the prompt's own text.
75
+ */
76
+ export function embeddingOption(argv) {
77
+ const words = codexArguments(argv);
78
+ if (words === null) return null;
79
+ let value = false;
80
+ let opened = false;
81
+ for (const word of words) {
82
+ if (value) { value = false; continue; }
83
+ // `--` ends the options: what follows is the prompt, however it reads.
84
+ if (word === "--") return null;
85
+ if (word.startsWith("-")) {
86
+ if (EMBEDDING_OPTIONS.has(optionName(word))) return optionName(word);
87
+ value = VALUE_OPTIONS.has(word);
88
+ continue;
89
+ }
90
+ if (opened || !OPENS_CHAT.has(word)) return null;
91
+ opened = true;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * For a host that was read and is an interactive TUI, `{ launchOption }` - the
98
+ * option that keeps it embedded, or null when its command line shows none.
99
+ * Null for any other host or an unreadable one; never throws.
100
+ */
101
+ export async function embeddedHost(pid, argvOf = hostArgv) {
60
102
  try {
61
- return interactiveHost(await argvOf(pid));
103
+ const argv = await argvOf(pid);
104
+ return interactiveHost(argv) ? { launchOption: embeddingOption(argv) } : null;
62
105
  } catch {
63
- return false;
106
+ return null;
64
107
  }
65
108
  }
@@ -138,15 +138,25 @@ export function allowOutcome() {
138
138
  /**
139
139
  * Context for the next turn.
140
140
  *
141
- * This client wraps nothing: a UserPromptSubmit hook's stdout arrives at the
142
- * model as a `developer` role message, verbatim. So the injection is plain
143
- * text - emitting Claude Code's JSON envelope would put the envelope itself
144
- * into the conversation, the same mistake it would be on Kimi Code.
141
+ * This client wraps nothing: a UserPromptSubmit hook's plain stdout arrives at
142
+ * the model as a `developer` role message, verbatim, so the ordinary injection
143
+ * is plain text. Codex also parses a JSON answer - 0.147.0, 0.155.1 and 0.159.1
144
+ * took `hookSpecificOutput.additionalContext` into the same developer message
145
+ * and kept the envelope out of the conversation, measured 2026-09-29 - which
146
+ * is how a notice for the user rides beside the context.
145
147
  *
146
148
  * The developer role is the most direct channel of the four, which is a reason
147
149
  * to be careful with what goes into it: anything a peer wrote must stay framed
148
150
  * as data, because at this role the model reads text as instruction.
149
151
  */
150
- export function injectOutcome(context) {
151
- return { stdout: context, stderr: "", exitCode: 0 };
152
+ export function injectOutcome(context, notice) {
153
+ if (typeof notice?.userMessage !== "string") return { stdout: context, stderr: "", exitCode: 0 };
154
+ // A line the user must see rides as the hook's systemMessage, which Codex
155
+ // shows in the chat and never sends to the model; the context goes to the
156
+ // model as additionalContext, the same developer message plain text becomes.
157
+ // Measured with a probe hook on 0.147.0, 0.155.1 and 0.159.1. Only the ask's
158
+ // one turn prints JSON; every other turn keeps the plain text above.
159
+ return { stdout: JSON.stringify({ systemMessage: notice.userMessage,
160
+ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: context } }),
161
+ stderr: "", exitCode: 0 };
152
162
  }
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { mkdir, rm, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
- import { decisionBody } from "@agents-can-communicate/adapter-sdk";
5
+ import { decisionBody, isLaunchOption } from "@agents-can-communicate/adapter-sdk";
6
6
 
7
7
  import { CODEX_QUEUE_MINIMUM, MINIMUM_VERSION, PROTOCOL_CONTRACT, QUEUE_MODES,
8
8
  addCodexQueueMessage, canonicalCwd, compareVersions, controlSocketPath,
@@ -10,7 +10,7 @@ import { CODEX_QUEUE_MINIMUM, MINIMUM_VERSION, PROTOCOL_CONTRACT, QUEUE_MODES,
10
10
  versionOrder } from "./app-server-client.mjs";
11
11
  import { newEndpointId, readNativeEndpoint, readySocketPath, removeNativeEndpoint, socketIsReady,
12
12
  writeNativeEndpoint } from "./native-endpoint.mjs";
13
- import { hostArgv, runsEmbedded } from "./embedded-host.mjs";
13
+ import { embeddedHost, hostArgv } from "./embedded-host.mjs";
14
14
 
15
15
  // The receiver's hook supplies thread and cwd. Core holds only a random endpoint
16
16
  // reference; sender environment never decides which daemon receives the message.
@@ -110,8 +110,10 @@ export async function verifyReceiver(peer, endpoint, { probe = probeCodexQueue,
110
110
  export async function bindNativeSession({ argvOf = hostArgv, ...options } = {}) {
111
111
  const result = await bindAttempt(options);
112
112
  if (result.supported || !Number.isInteger(options.clientPid) || options.clientPid <= 0) return result;
113
- return await runsEmbedded(options.clientPid, argvOf)
114
- ? closed(options.clientVersion, "client_session_embedded") : 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 };
115
117
  }
116
118
 
117
119
  async function bindAttempt({ event, clientPid, clientVersion, runtimeDir,
@@ -208,10 +210,30 @@ export async function offerMessage({ binding, message, runtimeDir, timeoutMs = 5
208
210
  export const retireNativeSession = ({ binding, runtimeDir }) =>
209
211
  removeNativeEndpoint({ runtimeDir, endpointId: binding?.opaqueEndpointRef });
210
212
 
211
- const EMBEDDED_NOTICE = "ACC: live peer delivery is off in this Codex chat. It runs on its own "
212
- + "embedded app server, so peer messages reach it only with the user's next prompt. Tell the "
213
- + "user once: for live delivery, open a new Codex chat; if Codex does not start its app-server "
214
- + "daemon itself, run `codex app-server daemon start` first.";
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
+ }
215
237
 
216
238
  // One delivered notice per chat. The marker is created exclusively, so a
217
239
  // second turn finds it; a notice the runner did not deliver gives it back.
@@ -227,7 +249,7 @@ export async function nativeActivationHint({ event, nativeBinding, runtimeDir })
227
249
  } catch {
228
250
  return null;
229
251
  }
230
- return { line: EMBEDDED_NOTICE, release: () => rm(marker, { force: true }) };
252
+ return { ...embeddedNotice(nativeBinding.launchOption), release: () => rm(marker, { force: true }) };
231
253
  }
232
254
 
233
255
  function renderText(message) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.8.4",
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.4",
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.4",
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.4",
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.4",
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");
@@ -50,6 +50,13 @@ export const PROBE_KEYS = ["supported", "clientVersion", "protocolContract", "ex
50
50
  "modes", "reasonCode"];
51
51
  export const HANDSHAKE_KEYS = ["supported", "clientVersion", "protocolContract", "modes",
52
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);
53
60
 
54
61
  export function usage(message, details = {}) {
55
62
  throw new AccError(EXIT.USAGE, message, details);
@@ -58,10 +65,10 @@ export function usage(message, details = {}) {
58
65
  export const isPlainObject = value => value !== null && typeof value === "object" && !Array.isArray(value);
59
66
  export const isText = value => typeof value === "string" && value !== "";
60
67
 
61
- export function closed(value, keys, label) {
68
+ export function closed(value, keys, label, optional = []) {
62
69
  if (!isPlainObject(value)) usage(`${label} must be an object`);
63
70
  for (const key of Object.keys(value)) {
64
- if (!keys.includes(key)) usage(`unknown ${label} field ${key}`, { key });
71
+ if (!keys.includes(key) && !optional.includes(key)) usage(`unknown ${label} field ${key}`, { key });
65
72
  }
66
73
  for (const key of keys) {
67
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.4",
3
+ "version": "0.8.5",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -164,7 +164,8 @@ export function recordedText(message, delivery) {
164
164
  ? "live offer blocked by sender permissions; message remains queued; run acc doctor in the sending session"
165
165
  : item.errorCode === "no_live_transport"
166
166
  ? `${item.recipientParticipantId} has no live transport`
167
- + `${item.nativeReasonCode ? ` (${describeNativeReason(item.nativeReasonCode)})` : ""}`
167
+ + `${item.nativeReasonCode ? ` (${describeNativeReason(item.nativeReasonCode,
168
+ { launchOption: item.nativeLaunchOption })})` : ""}`
168
169
  + "; the message waits in its inbox"
169
170
  : item.errorCode === "recipient_offline" && item.endReason === "clear"
170
171
  ? `${item.recipientParticipantId}'s conversation was cleared (/clear); the message waits `
@@ -2,6 +2,7 @@ import { realpath } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
4
  import { activatePending, reclaimGenerations } from "./activation.mjs";
5
+ import { listRuntimeHolds } from "./leases.mjs";
5
6
  import { downloadRelease, fetchRelease, newerVersion } from "./download.mjs";
6
7
  import { withManagerLock } from "./mutex.mjs";
7
8
  import { readControl, writeControl, writeManagedJson } from "./state.mjs";
@@ -82,10 +83,24 @@ export async function reclaimAsActive(root, generationRoot) {
82
83
  const control = await readControl(root);
83
84
  if (control === null || await realpath(generationRoot) !== await realpath(control.active.root)) return;
84
85
  const result = await reclaimGenerations({ root });
85
- // A postponed pass is recorded as unfinished, so the scheduler tries again
86
- // (hourly) even with automatic updates off.
86
+ // The process that activated this generation still runs from the one it
87
+ // replaced, and its own lease keeps that generation through this pass -
88
+ // measured in the 0.8.5 upgrade preflight. That pass is unfinished, so the
89
+ // hourly retry removes the old generation once the process has exited. A
90
+ // worker started at once would race every command right after an update.
91
+ const active = await realpath(control.active.root);
92
+ const own = await listRuntimeHolds(root).then(async holds => {
93
+ for (const hold of holds) {
94
+ if (hold.pid === process.pid && await realpath(hold.runtime.root).catch(() => null) !== active) {
95
+ return true;
96
+ }
97
+ }
98
+ return false;
99
+ }, () => false);
100
+ // A postponed pass is recorded as unfinished too, so the scheduler tries
101
+ // again (hourly) even with automatic updates off.
87
102
  await writeManagedJson(path.join(root, RECLAIM_MARKER), { schemaVersion: 1,
88
- activeRoot: control.active.root, complete: result?.postponed !== true,
103
+ activeRoot: control.active.root, complete: result?.postponed !== true && !own,
89
104
  attemptedAt: new Date().toISOString() });
90
105
  } catch { /* Best effort; the next pass reclaims again. */ }
91
106
  }
@@ -120,7 +120,10 @@ function describeAttempt(attempt) {
120
120
  : attempt.policyStatus === "unavailable" ? `${source} delivery policy could not be read`
121
121
  : `${source} delivery policy was off`;
122
122
  } else if (attempt.state === "active") detail = "local handshake succeeded";
123
- else detail = failures[attempt.reasonCode] ?? describeNativeReason(attempt.reasonCode);
123
+ else if (attempt.reasonCode === "client_session_embedded" && attempt.launchOption) {
124
+ detail = `${describeNativeReason(attempt.reasonCode, { launchOption: attempt.launchOption })}; `
125
+ + `start a new client session without ${attempt.launchOption}`;
126
+ } else detail = failures[attempt.reasonCode] ?? describeNativeReason(attempt.reasonCode);
124
127
  return `last attempt ${attempt.at}: ${detail}`;
125
128
  }
126
129
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/core",
3
- "version": "0.8.4",
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/delivery-router",
3
- "version": "0.8.4",
3
+ "version": "0.8.5",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,7 +1,9 @@
1
1
  import { listSessionBindings, loadNativeAttempt } from "@agents-can-communicate/adapter-sdk";
2
2
 
3
3
  /**
4
- * Why a session has no live transport, as its own last native attempt said.
4
+ * Why a session has no live transport, as its own last native attempt said:
5
+ * `{ reasonCode, launchOption? }`, the option being the client launch option
6
+ * that attempt named as the cause.
5
7
  *
6
8
  * The same record `acc doctor` reads, found through the hook-owner file that
7
9
  * names this exact session generation. Null when none says: an attempt that
@@ -14,5 +16,7 @@ export async function lastNativeReason({ runtimeDir, sessionId, generation }) {
14
16
  if (owners.length !== 1) return null;
15
17
  const attempt = await loadNativeAttempt({ runtimeDir,
16
18
  harnessSessionId: owners[0].harnessSessionId, accSessionId: sessionId, generation });
17
- return attempt !== null && attempt.state !== "active" ? attempt.reasonCode : null;
19
+ if (attempt === null || attempt.state === "active") return null;
20
+ return { reasonCode: attempt.reasonCode,
21
+ ...(attempt.launchOption ? { launchOption: attempt.launchOption } : {}) };
18
22
  }
@@ -1,4 +1,5 @@
1
- import { NATIVE_REASON_CODES, evaluateVersionContract } from "@agents-can-communicate/adapter-sdk";
1
+ import { NATIVE_REASON_CODES, evaluateVersionContract, isLaunchOption }
2
+ from "@agents-can-communicate/adapter-sdk";
2
3
 
3
4
  import { lastNativeReason } from "./native-reason.mjs";
4
5
  import { refreshExpiredBinding } from "./refresh-binding.mjs";
@@ -51,13 +52,16 @@ export function createDeliveryRouter({ service, adapters, clock, readLivePolicy,
51
52
  const registry = adaptersById(adapters);
52
53
 
53
54
  // A session that bound no live transport may have said why: the reason its
54
- // own last native attempt recorded, from the closed vocabulary only.
55
+ // own last native attempt recorded, from the closed vocabulary only, and the
56
+ // client launch option behind it when the attempt named one.
55
57
  async function withoutLiveTransport(participantId, target) {
56
- const reason = await Promise.resolve()
58
+ const found = await Promise.resolve()
57
59
  .then(() => readNativeReason({ sessionId: target.sessionId, generation: target.generation }))
58
60
  .catch(() => null);
59
- return { ...durable(participantId, "no_live_transport"),
60
- ...(NATIVE_REASON_CODES.includes(reason) ? { nativeReasonCode: reason } : {}) };
61
+ const reason = found?.reasonCode;
62
+ if (!NATIVE_REASON_CODES.includes(reason)) return durable(participantId, "no_live_transport");
63
+ return { ...durable(participantId, "no_live_transport"), nativeReasonCode: reason,
64
+ ...(isLaunchOption(found.launchOption) ? { nativeLaunchOption: found.launchOption } : {}) };
61
65
  }
62
66
 
63
67
  // Read at offer time, not from the binding: consent withdrawn after a session
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/hook-runner",
3
- "version": "0.8.4",
3
+ "version": "0.8.5",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -45,6 +45,7 @@ export async function bindNative({ adapter, event, hookBinding, clientVersion, p
45
45
  reasonCode: result.reasonCode, policy, policySource, policyStatus,
46
46
  clientProcess: Number.isInteger(hookBinding.clientPid) && hookBinding.clientPid > 0
47
47
  ? "identified" : "unknown",
48
+ ...(result.launchOption === undefined ? {} : { launchOption: result.launchOption }),
48
49
  } }).catch(() => {});
49
50
  }
50
51
  return result;
@@ -57,6 +58,7 @@ function asHint(value) {
57
58
  if (typeof value === "string") return { line: value, release: async () => {} };
58
59
  if (value !== null && typeof value === "object" && typeof value.line === "string") {
59
60
  return { line: value.line,
61
+ ...(usableHint(value.userMessage) ? { userMessage: value.userMessage } : {}),
60
62
  release: typeof value.release === "function" ? value.release : async () => {} };
61
63
  }
62
64
  if (value !== null && typeof value === "object" && typeof value.release === "function") {
@@ -66,7 +68,8 @@ function asHint(value) {
66
68
  }
67
69
 
68
70
  function usableHint(line) {
69
- return line !== "" && !/[\r\n]/.test(line) && Buffer.byteLength(line, "utf8") <= HINT_MAX_BYTES;
71
+ return typeof line === "string" && line !== "" && !/[\r\n]/.test(line)
72
+ && Buffer.byteLength(line, "utf8") <= HINT_MAX_BYTES;
70
73
  }
71
74
 
72
75
  // An adapter may release with a synchronous function. Calling `.catch` on its
@@ -91,6 +94,8 @@ export function callRelease(release) {
91
94
  * A string has nothing reserved. `{ line, release }` has reserved an ask; this
92
95
  * function calls `release` when it drops the line, including when the adapter
93
96
  * answers after the budget. The caller releases a line it does not deliver.
97
+ * `{ line, userMessage }` also asks for a line the user sees and the model
98
+ * does not, held to the same bound; one that does not fit is dropped alone.
94
99
  */
95
100
  export async function nativeActivationHintFor({ adapter, event, nativeBinding, binding, context,
96
101
  paths, deadline }) {
@@ -37,8 +37,11 @@ function discardEndpoint(adapter, handshake, runtimeDir) {
37
37
  export async function establishNativeBinding({ adapter, event, hookBinding, clientVersion,
38
38
  platform, livePolicy, service, runtimeDir, clock, env,
39
39
  timeoutMs = DEFAULT_TIMEOUT_MS, heartbeatCadenceMs = DEFAULT_CADENCE_MS }) {
40
- const outcome = (state, reasonCode, modes = []) =>
41
- Object.freeze({ state, reasonCode, modes: Object.freeze([...modes]) });
40
+ // A refusal may name the client launch option behind it; see
41
+ // validateNativeHandshake, which admits only a bare option token.
42
+ const outcome = (state, reasonCode, modes = [], launchOption = null) =>
43
+ Object.freeze({ state, reasonCode, modes: Object.freeze([...modes]),
44
+ ...(launchOption === null ? {} : { launchOption }) });
42
45
  const sessionId = hookBinding?.accSessionId;
43
46
  const generation = hookBinding?.generation;
44
47
  if (typeof sessionId !== "string" || typeof generation !== "string") return outcome("off", null);
@@ -80,7 +83,7 @@ export async function establishNativeBinding({ adapter, event, hookBinding, clie
80
83
  if (!verdict.ok) {
81
84
  await discardEndpoint(adapter, handshake, runtimeDir);
82
85
  return outcome(STATIC_REASONS.has(verdict.reasonCode) ? "unsupported" : "degraded",
83
- verdict.reasonCode);
86
+ verdict.reasonCode, [], verdict.launchOption ?? null);
84
87
  }
85
88
  const now = Date.parse(clock.now());
86
89
  const lease = Date.parse(verdict.leaseUntil);
@@ -238,8 +238,8 @@ async function openContext({ event, adapterId, dataHome, runtime, env, deadline
238
238
  // offered. Runs after the heartbeat and the bounded native retry.
239
239
  // `render` is how the projected text reaches this client: before a turn it is
240
240
  // the adapter's context envelope, at the end of one it is a continuation.
241
- async function projectTurn({ binding, context, adapter, adapterId,
242
- render = text => adapter.injectOutcome?.(text), activationHint = null, repeats = true }) {
241
+ async function projectTurn({ binding, context, adapter, adapterId, render = null,
242
+ activationHint = null, activationNotice = null, repeats = true }) {
243
243
  const sync = await context.service.sync({ sessionId: binding.accSessionId,
244
244
  cursor: null, scope: "delta" });
245
245
 
@@ -283,11 +283,17 @@ async function projectTurn({ binding, context, adapter, adapterId,
283
283
  const owner = hintFits ? `${header}\n${activationHint}` : header;
284
284
  const withHint = (outcome, attached) => ({ ...outcome,
285
285
  activationHintIncluded: attached === true && outcome.stdout !== "" });
286
+ // The ask's line for the user travels with its line for the model, or not
287
+ // at all: the adapter shows it where the client shows a hook's message.
288
+ const notice = hintFits && activationNotice !== null ? { userMessage: activationNotice } : null;
289
+ const inject = text => notice === null ? adapter.injectOutcome?.(text)
290
+ : adapter.injectOutcome?.(text, notice);
291
+ const renderText = render ?? inject;
286
292
  // A peer can join after this prompt has begun. The current turn must already
287
293
  // have its own arguments when it needs inbox/reply, without reattaching or
288
294
  // waiting for another user prompt. Solo emits identity, not a peer notice.
289
295
  if (sync.solo && messages.length === 0) {
290
- return withHint(ownerOnlyOutcome(text => adapter.injectOutcome?.(text), owner, totalBudget),
296
+ return withHint(ownerOnlyOutcome(inject, owner, totalBudget),
291
297
  hintFits);
292
298
  }
293
299
 
@@ -336,7 +342,7 @@ async function projectTurn({ binding, context, adapter, adapterId,
336
342
  // Own claims make sync non-solo but produce no peer context. They cannot
337
343
  // remove this turn's identity or consume a nonexistent body separator.
338
344
  if (body === "" && messages.length === 0) {
339
- return withHint(ownerOnlyOutcome(text => adapter.injectOutcome?.(text), owner, totalBudget),
345
+ return withHint(ownerOnlyOutcome(inject, owner, totalBudget),
340
346
  hintFits);
341
347
  }
342
348
  const projected = body === "" ? "" : ownerFits ? `${owner}\n${body}` : body;
@@ -361,7 +367,7 @@ async function projectTurn({ binding, context, adapter, adapterId,
361
367
  // The entry point owns the transport boundary. This handler only prepares
362
368
  // offer inputs; recording them here would claim delivery before stdout's
363
369
  // callback proves that the bytes crossed.
364
- const outcome = { stdout: "", ...render(projected) };
370
+ const outcome = { stdout: "", ...renderText(projected) };
365
371
  const writableOffers = outcome.stdout === "" ? [] : offerInputs;
366
372
  return withHint({ ...outcome,
367
373
  stderr: [outcome.stderr, ownerWarning, degradation].filter(Boolean).join("\n"),
@@ -373,7 +379,8 @@ async function projectTurn({ binding, context, adapter, adapterId,
373
379
  // so the reservation goes with it.
374
380
  async function projectActivation(input, offered) {
375
381
  try {
376
- const turn = await projectTurn({ ...input, activationHint: offered?.line ?? null });
382
+ const turn = await projectTurn({ ...input, activationHint: offered?.line ?? null,
383
+ activationNotice: offered?.userMessage ?? null });
377
384
  const included = turn.activationHintIncluded === true;
378
385
  delete turn.activationHintIncluded;
379
386
  if (!offered) return turn;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/installer",
3
- "version": "0.8.4",
3
+ "version": "0.8.5",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,3 +1,5 @@
1
+ import { isLaunchOption } from "@agents-can-communicate/adapter-sdk";
2
+
1
3
  // Shared presentation of observed delivery facts. Vendor probes provide closed
2
4
  // reasons; installation and doctor must not turn a version floor into readiness.
3
5
  const REASONS = Object.freeze({
@@ -16,10 +18,16 @@ const REASONS = Object.freeze({
16
18
  + "cannot reach",
17
19
  });
18
20
 
19
- export function describeNativeReason(reason, { clientVersion, minimumVersion } = {}) {
21
+ export function describeNativeReason(reason, { clientVersion, minimumVersion, launchOption } = {}) {
20
22
  if (reason === "below_minimum_version" && clientVersion && minimumVersion) {
21
23
  return `client ${clientVersion} needs version ${minimumVersion} or newer for native delivery`;
22
24
  }
25
+ // A client can run embedded because of how it was started - Codex does for
26
+ // `--search`, `-c`, `--profile` and others while its daemon runs.
27
+ if (reason === "client_session_embedded" && isLaunchOption(launchOption)) {
28
+ return `the client was started with ${launchOption}, which runs this session on its own `
29
+ + "embedded service that peers cannot reach";
30
+ }
23
31
  return REASONS[reason] ?? reason ?? "readiness is unverified";
24
32
  }
25
33
  export const describeDeliveryFallback = entry => entry.capabilities?.delivery?.nextTurn === true
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/mcp-server",
3
- "version": "0.8.4",
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/protocol",
3
- "version": "0.8.4",
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/storage-filesystem",
3
- "version": "0.8.4",
3
+ "version": "0.8.5",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.8.4",
3
+ "version": "0.8.5",
4
4
  "accManagedUpdateProtocol": 2,
5
5
  "accStoreVersion": 6,
6
6
  "type": "module",