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.
- package/docs/ADAPTER_AUTHORING.md +3 -0
- package/docs/ARCHITECTURE.md +7 -4
- package/docs/TROUBLESHOOTING.md +11 -4
- package/docs/UPGRADING.md +17 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/src/embedded-host.mjs +47 -4
- package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +16 -6
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +31 -9
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +2 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-attempt.mjs +6 -2
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +17 -7
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +9 -2
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +2 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +18 -3
- package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +4 -1
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
- package/node_modules/@agents-can-communicate/delivery-router/src/native-reason.mjs +6 -2
- package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +9 -5
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/native-attempt.mjs +6 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +6 -3
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +13 -6
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/src/delivery-diagnostics.mjs +9 -1
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- 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
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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
|
|
235
|
-
|
|
236
|
-
recorded in `reclaim.json
|
|
237
|
-
|
|
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.
|
package/docs/TROUBLESHOOTING.md
CHANGED
|
@@ -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
|
|
143
|
-
a sender sees the cause beside `no live transport`, and doctor names it on
|
|
144
|
-
Open a new Codex chat while the service runs; start it with
|
|
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
|
|
@@ -55,11 +55,54 @@ export function interactiveHost(argv) {
|
|
|
55
55
|
return true;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
103
|
+
const argv = await argvOf(pid);
|
|
104
|
+
return interactiveHost(argv) ? { launchOption: embeddingOption(argv) } : null;
|
|
62
105
|
} catch {
|
|
63
|
-
return
|
|
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
|
|
142
|
-
* model as a `developer` role message, verbatim
|
|
143
|
-
* text
|
|
144
|
-
*
|
|
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 {
|
|
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
|
-
|
|
114
|
-
|
|
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
|
|
212
|
-
+ "
|
|
213
|
-
|
|
214
|
-
|
|
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 {
|
|
252
|
+
return { ...embeddedNotice(nativeBinding.launchOption), release: () => rm(marker, { force: true }) };
|
|
231
253
|
}
|
|
232
254
|
|
|
233
255
|
function renderText(message) {
|
|
@@ -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,
|
|
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,
|
|
2
|
-
PLANNABLE_ACTIVATION_KINDS, PROBE_KEYS, TIMESTAMP, assertModes,
|
|
3
|
-
compareStableVersions, deepFreeze, isPlainObject, isText,
|
|
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 }
|
|
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)
|
|
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 });
|
|
@@ -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
|
-
//
|
|
86
|
-
//
|
|
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
|
|
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,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
|
-
|
|
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 }
|
|
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
|
|
58
|
+
const found = await Promise.resolve()
|
|
57
59
|
.then(() => readNativeReason({ sessionId: target.sessionId, generation: target.generation }))
|
|
58
60
|
.catch(() => null);
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
@@ -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)
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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: "", ...
|
|
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,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
|