agents-can-communicate 0.2.0 → 0.3.1
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/README.md +91 -81
- package/SECURITY.md +7 -4
- package/bin/acc-bootstrap.mjs +56 -0
- package/bin/acc-claude-channel.mjs +177 -0
- package/bin/acc.mjs +7 -2
- package/docs/ADAPTER_AUTHORING.md +57 -21
- package/docs/ARCHITECTURE.md +23 -15
- package/docs/CAPABILITIES.md +51 -18
- package/docs/CLI.md +31 -13
- package/docs/CONCEPTS.md +91 -94
- package/docs/CONFIGURATION.md +33 -23
- package/docs/DESIGN_DECISIONS.md +8 -4
- package/docs/GETTING_STARTED.md +76 -100
- package/docs/GLOSSARY.md +4 -4
- package/docs/HOW_IT_WORKS.md +163 -0
- package/docs/MCP.md +8 -8
- package/docs/PROTOCOL.md +13 -8
- package/docs/RELEASING.md +112 -82
- package/docs/SECURITY_MODEL.md +13 -8
- package/docs/TROUBLESHOOTING.md +30 -12
- package/docs/WHY_ACC.md +18 -16
- package/docs/index.md +26 -16
- package/node_modules/@agents-can-communicate/adapter-claude-code/certification.json +196 -46
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/certification-provenance.json +237 -45
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.252.json +3 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.258.json +23 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.260.json +23 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +5 -2
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.mcp.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +10 -6
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +34 -2
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/channel.mjs +377 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +27 -7
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +229 -0
- package/node_modules/@agents-can-communicate/adapter-codex/certification.json +38 -5
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/certification-provenance.json +171 -38
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.0.json +3 -1
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-remote-workspace.json +25 -0
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +4 -2
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +9 -5
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +42 -7
- package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +121 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +151 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +192 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/certification.json +44 -28
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +9 -5
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/{BeforeAgent.json → BeforeAgent-0.57.0.json} +2 -2
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/{BeforeTool.json → BeforeTool-0.57.0.json} +2 -2
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/{BeforeTool-shell.json → BeforeTool-shell-0.57.0.json} +2 -2
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/{SessionEnd.json → SessionEnd-0.57.0.json} +2 -2
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/{SessionStart.json → SessionStart-0.57.0.json} +2 -2
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/certification-provenance.json +266 -39
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +6 -6
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +25 -11
- package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +9 -5
- package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +3 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +9 -5
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +18 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +2 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +6 -2
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-activation.mjs +76 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +202 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +101 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +17 -2
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +2 -2
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +67 -5
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +133 -11
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/conversations.mjs +41 -1
- package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +52 -2
- package/node_modules/@agents-can-communicate/core/src/service.mjs +10 -0
- package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
- package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +26 -12
- package/node_modules/@agents-can-communicate/hook-runner/package.json +4 -2
- package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +90 -0
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +123 -90
- package/node_modules/@agents-can-communicate/installer/package.json +4 -2
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +48 -4
- package/node_modules/@agents-can-communicate/installer/src/bootstrap-runtime.mjs +144 -0
- package/node_modules/@agents-can-communicate/installer/src/detect.mjs +72 -3
- package/node_modules/@agents-can-communicate/installer/src/index.mjs +7 -0
- package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +161 -0
- package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +6 -2
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +41 -6
- package/node_modules/@agents-can-communicate/installer/src/shell-bootstrap.mjs +210 -0
- 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/protocol/src/fields.mjs +17 -0
- package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +18 -3
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/package.json +2 -1
|
@@ -6,7 +6,8 @@ import { createCodexAdapter } from "@agents-can-communicate/adapter-codex";
|
|
|
6
6
|
import { createGeminiCliAdapter } from "@agents-can-communicate/adapter-gemini-cli";
|
|
7
7
|
import { createGrokAdapter } from "@agents-can-communicate/adapter-grok";
|
|
8
8
|
import { createKimiAdapter } from "@agents-can-communicate/adapter-kimi";
|
|
9
|
-
import { applyPlan, detectInstallation,
|
|
9
|
+
import { LIVE_POLICIES, applyPlan, detectInstallation, livePolicyOf,
|
|
10
|
+
loadOwnership, planInstallation, rcFileFor, shellOf, shimDirFor }
|
|
10
11
|
from "@agents-can-communicate/installer";
|
|
11
12
|
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
12
13
|
|
|
@@ -18,13 +19,18 @@ import { platformPaths } from "./platform-paths.mjs";
|
|
|
18
19
|
// Each client keeps its own directory under the user's home, and an adapter
|
|
19
20
|
// pointed at the home itself writes beside them rather than inside them. That
|
|
20
21
|
// install reports success and the client never reads a byte of it.
|
|
21
|
-
export const clientContext = (home, stateRoot) => ({
|
|
22
|
+
export const clientContext = (home, stateRoot, { shell = null, env = {} } = {}) => ({
|
|
22
23
|
home,
|
|
23
24
|
configDir: path.join(home, ".claude"),
|
|
24
25
|
agentsHome: home,
|
|
25
26
|
codexHome: path.join(home, ".codex"),
|
|
26
27
|
kimiHome: path.join(home, ".kimi-code"),
|
|
27
28
|
grokHome: path.join(home, ".grok"),
|
|
29
|
+
// The user's login shell and PATH, for the optional native shell bootstrap:
|
|
30
|
+
// which rc file could carry an ACC PATH block and which real executable a
|
|
31
|
+
// shim would exec. Detection reads them; nothing here writes.
|
|
32
|
+
shell,
|
|
33
|
+
env,
|
|
28
34
|
// Where ACC keeps its own state, for the client that has to be told. Codex
|
|
29
35
|
// sandboxes the commands a model runs to the workspace, and ACC's state is
|
|
30
36
|
// outside every workspace on purpose - so an agent there could read the
|
|
@@ -108,11 +114,20 @@ export function describeChanges(operation, home) {
|
|
|
108
114
|
return [...artifacts.filter(artifact => artifact.kind !== "merge")
|
|
109
115
|
.map(artifact => ` created ${shorten(artifact.path, home)}`), ...edited];
|
|
110
116
|
}
|
|
117
|
+
// An uninstall's merge edits come from the plan - every file ACC has ever
|
|
118
|
+
// written into - while everything else on this list is decided by the run.
|
|
119
|
+
// Printed unconditionally, a second uninstall told the operator it had edited
|
|
120
|
+
// six of their configuration files whose bytes it had not touched. `changes`
|
|
121
|
+
// is what the adapter actually took out, and is already what the packed
|
|
122
|
+
// verifier calls a no-op, so the report now follows the same definition. It
|
|
123
|
+
// is still the plan's list rather than a per-file one: this says whether
|
|
124
|
+
// anything was edited, not which of several files it was.
|
|
125
|
+
const editedIfAnything = (operation.changes ?? []).length === 0 ? [] : edited;
|
|
111
126
|
return [
|
|
112
127
|
...(operation.removed ?? []).map(file => ` removed ${shorten(file, home)}`),
|
|
113
128
|
...(operation.removedDirectories ?? [])
|
|
114
129
|
.map(file => ` removed ${shorten(file, home)}`),
|
|
115
|
-
...
|
|
130
|
+
...editedIfAnything,
|
|
116
131
|
...(operation.kept ?? [])
|
|
117
132
|
.map(file => ` kept ${shorten(file, home)} - changed since ACC wrote it`),
|
|
118
133
|
];
|
|
@@ -174,12 +189,111 @@ export function actedOn(result) {
|
|
|
174
189
|
+ (operation.changes?.length ?? 0) > 0)).length;
|
|
175
190
|
}
|
|
176
191
|
|
|
192
|
+
const isInteractive = runtime => typeof runtime.isInteractive === "function"
|
|
193
|
+
&& runtime.isInteractive() === true;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* One default-No question per eligible client, asked only after detection has
|
|
197
|
+
* finished and only when a person is at both ends of the terminal.
|
|
198
|
+
*
|
|
199
|
+
* Written for someone who has never heard of this project. It used to open with
|
|
200
|
+
* "Enable native live delivery" over a list of internal artefact names, which
|
|
201
|
+
* told a first-time reader neither what they would gain nor what it would cost -
|
|
202
|
+
* and left out the part that matters most: saying yes makes their own `claude`
|
|
203
|
+
* start with the vendor's `--dangerously-load-development-channels` flag. A
|
|
204
|
+
* consent prompt that hides the loudest consequence is not consent.
|
|
205
|
+
*
|
|
206
|
+
* So the order is the reader's, not the installer's: what changes for them,
|
|
207
|
+
* what it costs, what it touches, and that "no" costs them nothing.
|
|
208
|
+
*/
|
|
209
|
+
function questionFor(entry, context, { onlyEligible = false } = {}) {
|
|
210
|
+
const bootstrap = entry.nativeDelivery.activationPlan.mechanisms
|
|
211
|
+
.find(mechanism => mechanism.kind === "shell-bootstrap") ?? null;
|
|
212
|
+
// The presence of a flag is what makes this worth asking about at all: it is
|
|
213
|
+
// why the client will warn at every start from now on. Which flag it is, and
|
|
214
|
+
// that a shim adds it, are answers to a question nobody is asking here - the
|
|
215
|
+
// shim `exec`s and is gone, so nothing runs "through" acc afterwards either.
|
|
216
|
+
const flags = (bootstrap?.prefixArgs ?? []).filter(argument => argument.startsWith("--"));
|
|
217
|
+
void context;
|
|
218
|
+
// Why the default is No, and why only one client is being asked about, are
|
|
219
|
+
// the two questions the bare prompt provokes and never answered: a `[y/N]`
|
|
220
|
+
// reads as "not recommended" on its own, and a single question among four
|
|
221
|
+
// installed clients reads as an omission. Both have the same short answer.
|
|
222
|
+
const aside = ["experimental", ...(onlyEligible ? [`${entry.displayName} only`] : [])]
|
|
223
|
+
.join("; ");
|
|
224
|
+
return [
|
|
225
|
+
`Let idle agents answer each other while you are away? (${aside})`,
|
|
226
|
+
...(flags.length === 0 || bootstrap === null
|
|
227
|
+
? [" Yes: they reply without waiting for you."]
|
|
228
|
+
: [` Yes: they reply without waiting for you, and ${entry.displayName} asks you`,
|
|
229
|
+
" to allow development channels every time it starts."]),
|
|
230
|
+
" No: messages still arrive, at the session's next turn.",
|
|
231
|
+
].join("\n");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Which live policy each selected client gets.
|
|
236
|
+
*
|
|
237
|
+
* An explicit --delivery applies uniformly and never prompts. Otherwise a
|
|
238
|
+
* recorded opt-in is kept as it was consented to, a fresh or never-activated
|
|
239
|
+
* client is off unless a person answers yes here, and a dry run or a
|
|
240
|
+
* non-interactive stdin/stdout makes no interactive choice at all.
|
|
241
|
+
*/
|
|
242
|
+
export async function decideDelivery({ options, detected, recorded, runtime, dryRun, context }) {
|
|
243
|
+
const explicit = options.delivery;
|
|
244
|
+
if (explicit !== undefined && !LIVE_POLICIES.includes(explicit)) {
|
|
245
|
+
throw new AccError(EXIT.USAGE, `unknown delivery policy: ${explicit}`, { delivery: explicit });
|
|
246
|
+
}
|
|
247
|
+
const recordedById = new Map(recorded.map(install => [install.adapterId, install]));
|
|
248
|
+
// Whether this client is the only one that could be asked at all. When it is,
|
|
249
|
+
// the question says so, because otherwise being asked about one of four
|
|
250
|
+
// installed clients looks like the other three were forgotten.
|
|
251
|
+
const onlyEligible = detected
|
|
252
|
+
.filter(candidate => candidate.nativeDelivery?.state === "eligible").length === 1;
|
|
253
|
+
const deliveryByAdapter = {};
|
|
254
|
+
const asked = [];
|
|
255
|
+
let withheld = 0;
|
|
256
|
+
for (const entry of detected) {
|
|
257
|
+
const eligible = entry.nativeDelivery?.state === "eligible";
|
|
258
|
+
const previous = livePolicyOf(recordedById.get(entry.adapterId));
|
|
259
|
+
if (explicit !== undefined) {
|
|
260
|
+
deliveryByAdapter[entry.adapterId] = explicit;
|
|
261
|
+
} else if (previous !== "off") {
|
|
262
|
+
deliveryByAdapter[entry.adapterId] = previous;
|
|
263
|
+
} else if (!eligible || dryRun || !isInteractive(runtime)) {
|
|
264
|
+
deliveryByAdapter[entry.adapterId] = "off";
|
|
265
|
+
if (eligible) withheld += 1;
|
|
266
|
+
} else {
|
|
267
|
+
const yes = await runtime.confirm(questionFor(entry, context, { onlyEligible }),
|
|
268
|
+
{ input: runtime.input, output: runtime.output }) === true;
|
|
269
|
+
deliveryByAdapter[entry.adapterId] = yes ? "actionable" : "off";
|
|
270
|
+
asked.push(entry.adapterId);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const notes = explicit === undefined && dryRun && withheld > 0
|
|
274
|
+
? ["interactive choices were not made: this preview keeps native delivery off for "
|
|
275
|
+
+ `${withheld} eligible client(s) without a recorded opt-in`]
|
|
276
|
+
: [];
|
|
277
|
+
return { deliveryByAdapter, asked, notes };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Said once, after the first PATH block is written: a running shell and a
|
|
281
|
+
// running client know nothing about it.
|
|
282
|
+
function reloadAdvice(operations) {
|
|
283
|
+
const appended = operations.filter(operation => operation.appendedRcBlock === true);
|
|
284
|
+
if (appended.length === 0) return [];
|
|
285
|
+
const rc = appended[0].nativeActivation?.rcFile ?? "your shell rc file";
|
|
286
|
+
return ["", `native delivery is wired for new sessions: open a new terminal (or reload ${rc}), `
|
|
287
|
+
+ "then start the client normally; a session already running keeps durable delivery"];
|
|
288
|
+
}
|
|
289
|
+
|
|
177
290
|
export async function runInstallCommand({ options, runtime, action = "install" }) {
|
|
178
291
|
const adapters = selectAdapters(options.adapter);
|
|
179
292
|
const home = options.home ?? runtime.env?.HOME ?? homedir();
|
|
180
293
|
const { data: dataHome } = platformPaths({ platform: runtime.platform,
|
|
181
294
|
env: runtime.env ?? {} });
|
|
182
|
-
const context = clientContext(home, path.join(dataHome, "acc")
|
|
295
|
+
const context = clientContext(home, path.join(dataHome, "acc"),
|
|
296
|
+
{ shell: shellOf(runtime.env), env: runtime.env ?? {} });
|
|
183
297
|
|
|
184
298
|
const detected = await detectInstallation({ adapters, context,
|
|
185
299
|
probeTimeoutMs: probeTimeout(runtime.env) });
|
|
@@ -205,20 +319,28 @@ export async function runInstallCommand({ options, runtime, action = "install" }
|
|
|
205
319
|
const requested = options.adapter === undefined
|
|
206
320
|
? []
|
|
207
321
|
: (Array.isArray(options.adapter) ? options.adapter : [options.adapter]);
|
|
322
|
+
// Decided only after detection, from the operator's explicit answer, the
|
|
323
|
+
// recorded consent, or a default of off; never from inference.
|
|
324
|
+
const decided = action === "install"
|
|
325
|
+
? await decideDelivery({ options, detected, recorded, runtime, dryRun, context })
|
|
326
|
+
: { deliveryByAdapter: {}, asked: [], notes: [] };
|
|
208
327
|
const plan = planInstallation({ adapters, detected, context, action, recorded,
|
|
209
328
|
accVersion, allowDowngrade: options.downgrade === true, requested,
|
|
210
|
-
|
|
329
|
+
deliveryByAdapter: decided.deliveryByAdapter });
|
|
211
330
|
const result = await applyPlan({ plan, adapters, context, dataHome, dryRun, accVersion });
|
|
212
331
|
|
|
213
332
|
const acted = actedOn(result);
|
|
214
333
|
if (dryRun) {
|
|
215
|
-
return { data: { ...result, plan, dataHome },
|
|
334
|
+
return { data: { ...result, plan, dataHome, deliveryByAdapter: decided.deliveryByAdapter },
|
|
216
335
|
text: [`would ${action}:`, ...plan.operations.flatMap(operation => operation.summary),
|
|
217
|
-
...plan.skipped.map(entry => `skip ${entry.adapterId}: ${entry.reason}`)
|
|
336
|
+
...plan.skipped.map(entry => `skip ${entry.adapterId}: ${entry.reason}`),
|
|
337
|
+
...decided.notes].join("\n") };
|
|
218
338
|
}
|
|
219
339
|
|
|
220
|
-
return { data: { ...result, plan, dataHome
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
340
|
+
return { data: { ...result, plan, dataHome, deliveryByAdapter: decided.deliveryByAdapter,
|
|
341
|
+
asked: decided.asked },
|
|
342
|
+
text: [describeOutcome({ action, acted, failed: result.failed,
|
|
343
|
+
skipped: plan.skipped, operations: result.operations, home }),
|
|
344
|
+
...reloadAdvice(result.operations)].join("\n"),
|
|
345
|
+
error: failureOf({ action, acted, failed: result.failed }) };
|
|
224
346
|
}
|
|
@@ -55,6 +55,41 @@ function assertCurrentSession(tx, session, action) {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Turn what the sender typed into participant ids that exist.
|
|
60
|
+
*
|
|
61
|
+
* An exact participant id is always taken as written. A name that matches no
|
|
62
|
+
* participant is tried as a client name - `codex`, `claude_code` - because that
|
|
63
|
+
* is what an agent knows about its peers: the roster it is handed names the
|
|
64
|
+
* client, and asking it to first look up `codex-fbqX8o` is a lookup it
|
|
65
|
+
* routinely skips. Every shipped example taught this form while nothing
|
|
66
|
+
* implemented it, so an agent following its own skill was refused on its first
|
|
67
|
+
* attempt to reach anyone.
|
|
68
|
+
*
|
|
69
|
+
* One live session of that client resolves. Two refuse and name them, the way
|
|
70
|
+
* `--session` already refuses to choose between two sessions of one client:
|
|
71
|
+
* guessing is how a question is answered by a session that never had the
|
|
72
|
+
* context. The sender's own client is excluded, or the one client an agent is
|
|
73
|
+
* certain is running becomes the easiest thing to address by accident.
|
|
74
|
+
*/
|
|
75
|
+
function resolveAddressees(tx, requested, session) {
|
|
76
|
+
const known = new Set(tx.list("participant").map(item => item.participantId));
|
|
77
|
+
return requested.map(name => {
|
|
78
|
+
if (known.has(name)) return name;
|
|
79
|
+
const peers = [...new Set(tx.list("session", item => item.state === "open"
|
|
80
|
+
&& item.harness === name && item.participantId !== session.participantId)
|
|
81
|
+
.map(item => item.participantId))].sort();
|
|
82
|
+
if (peers.length === 1) return peers[0];
|
|
83
|
+
if (peers.length > 1) {
|
|
84
|
+
throw new AccError(EXIT.USAGE,
|
|
85
|
+
`could not tell which of ${peers.length} ${name} sessions you meant: `
|
|
86
|
+
+ `${peers.join(", ")}. Name one of them, which \`acc status --json\` lists.`,
|
|
87
|
+
{ client: name, candidates: peers });
|
|
88
|
+
}
|
|
89
|
+
return name;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
58
93
|
function addressedRecipients(tx, message, session) {
|
|
59
94
|
if (message.toParticipantIds.length === 0) {
|
|
60
95
|
return [...new Set(tx.list("session", item => item.state === "open"
|
|
@@ -86,9 +121,14 @@ function threadFor(tx, messageId, inReplyTo) {
|
|
|
86
121
|
return parent.threadId;
|
|
87
122
|
}
|
|
88
123
|
|
|
89
|
-
export function recordMessageInTransaction({ tx, session, input, now, messageId, ids,
|
|
124
|
+
export function recordMessageInTransaction({ tx, session, input: raw, now, messageId, ids,
|
|
90
125
|
action = "send a message" }) {
|
|
91
126
|
assertCurrentSession(tx, session, action);
|
|
127
|
+
// Resolved before anything else reads the recipients, so the stored message
|
|
128
|
+
// names real participants and a retry compares against the same content. A
|
|
129
|
+
// record that kept the client name would address nobody when it is read back.
|
|
130
|
+
const input = { ...raw,
|
|
131
|
+
toParticipantIds: resolveAddressees(tx, raw.toParticipantIds ?? [], session) };
|
|
92
132
|
const existing = existingMessage(tx, session, input);
|
|
93
133
|
if (existing !== undefined) {
|
|
94
134
|
return { message: existing, recipientParticipantIds: [], created: false };
|
|
@@ -8,7 +8,7 @@ function conflict(sessionId) {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
export function createDeliveryBindingService(ports, sessions) {
|
|
11
|
-
const { store } = ports;
|
|
11
|
+
const { store, clock } = ports;
|
|
12
12
|
|
|
13
13
|
async function currentBindings(now) {
|
|
14
14
|
const bindings = await store.ephemeral.list("deliveryBinding");
|
|
@@ -17,6 +17,10 @@ export function createDeliveryBindingService(ports, sessions) {
|
|
|
17
17
|
const located = await sessions.locateSession(binding.sessionId);
|
|
18
18
|
if (located?.record.state !== "open"
|
|
19
19
|
|| located.record.generation !== binding.generation) continue;
|
|
20
|
+
// A retirement is final. It is kept as a record rather than deleted so a
|
|
21
|
+
// stale generation cannot race a successor's publication, and it must
|
|
22
|
+
// never be read back as a live endpoint.
|
|
23
|
+
if (binding.retiredAt !== null && binding.retiredAt !== undefined) continue;
|
|
20
24
|
current.push({ binding, participantId: located.record.participantId,
|
|
21
25
|
reachable: Date.parse(binding.leaseUntil) > Date.parse(now) });
|
|
22
26
|
}
|
|
@@ -42,6 +46,7 @@ export function createDeliveryBindingService(ports, sessions) {
|
|
|
42
46
|
livePolicy: input.livePolicy,
|
|
43
47
|
opaqueEndpointRef: input.opaqueEndpointRef,
|
|
44
48
|
leaseUntil: input.leaseUntil,
|
|
49
|
+
retiredAt: null,
|
|
45
50
|
});
|
|
46
51
|
} catch (error) {
|
|
47
52
|
if (error?.details?.field === "opaqueEndpointRef") {
|
|
@@ -71,11 +76,56 @@ export function createDeliveryBindingService(ports, sessions) {
|
|
|
71
76
|
return binding;
|
|
72
77
|
}
|
|
73
78
|
|
|
79
|
+
// Clearing is an in-place retirement under the same writer lock as
|
|
80
|
+
// publication. The store's update contract has no deletion sentinel and its
|
|
81
|
+
// delete() takes the same mutex, so this is the one atomic form: a stale
|
|
82
|
+
// generation cannot retire a successor's endpoint, and an absent or
|
|
83
|
+
// already-retired binding stays as it is.
|
|
84
|
+
//
|
|
85
|
+
// The retirement is recorded as its own fact rather than as an expired lease.
|
|
86
|
+
// Expiry and retirement used to be the same edit, so a channel still renewing
|
|
87
|
+
// its endpoint could extend a binding the session had already given up.
|
|
88
|
+
async function clearDeliveryBinding({ sessionId, generation }) {
|
|
89
|
+
await store.ephemeral.update("deliveryBinding", sessionId, async current => {
|
|
90
|
+
if (current === null || current === undefined) return null;
|
|
91
|
+
if (current.generation !== generation) throw conflict(sessionId);
|
|
92
|
+
if (current.retiredAt !== null && current.retiredAt !== undefined) return null;
|
|
93
|
+
const now = clock.now();
|
|
94
|
+
return { ...current, leaseUntil: now, retiredAt: now };
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Extend the lease on a binding whose endpoint is still being served.
|
|
100
|
+
*
|
|
101
|
+
* Only the process holding the endpoint knows it is alive, and for a client
|
|
102
|
+
* with no heartbeat nothing else refreshes this record: measured on a real
|
|
103
|
+
* Claude 2.1.259 session, the channel kept its registration renewed while
|
|
104
|
+
* this lease went stale about a minute after the last turn, and the router
|
|
105
|
+
* stopped offering to a session that was still serving.
|
|
106
|
+
*
|
|
107
|
+
* Narrow on purpose. It moves the lease and nothing else, refuses anything
|
|
108
|
+
* but the current open generation, and will not revive a retired binding.
|
|
109
|
+
*/
|
|
110
|
+
async function refreshDeliveryBinding({ sessionId, generation, leaseUntil }) {
|
|
111
|
+
const located = await sessions.locateSession(sessionId);
|
|
112
|
+
if (located?.record.state !== "open" || located.record.generation !== generation) {
|
|
113
|
+
throw conflict(sessionId);
|
|
114
|
+
}
|
|
115
|
+
await store.ephemeral.update("deliveryBinding", sessionId, async current => {
|
|
116
|
+
if (current === null || current === undefined) return null;
|
|
117
|
+
if (current.generation !== generation) throw conflict(sessionId);
|
|
118
|
+
if (current.retiredAt !== null && current.retiredAt !== undefined) return null;
|
|
119
|
+
return validateRecord("deliveryBinding", { ...current, leaseUntil });
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
74
123
|
async function listDeliveryBindings({ participantId, now }) {
|
|
75
124
|
return (await currentBindings(now))
|
|
76
125
|
.filter(item => item.participantId === participantId && item.reachable)
|
|
77
126
|
.map(item => item.binding);
|
|
78
127
|
}
|
|
79
128
|
|
|
80
|
-
return { publishDeliveryBinding,
|
|
129
|
+
return { publishDeliveryBinding, clearDeliveryBinding, refreshDeliveryBinding,
|
|
130
|
+
listDeliveryBindings, currentBindings };
|
|
81
131
|
}
|
|
@@ -43,11 +43,21 @@ export function createCoordinationService({ store, clock, ids,
|
|
|
43
43
|
ids,
|
|
44
44
|
policies: Object.freeze({ ...policies }),
|
|
45
45
|
...sessions,
|
|
46
|
+
// Closing a session also retires its live endpoint under the same
|
|
47
|
+
// generation, so nothing can be offered into a session that has left.
|
|
48
|
+
closeSession: async input => {
|
|
49
|
+
const closed = await sessions.closeSession(input);
|
|
50
|
+
await deliveryBindings.clearDeliveryBinding({ sessionId: input.sessionId,
|
|
51
|
+
generation: input.generation });
|
|
52
|
+
return closed;
|
|
53
|
+
},
|
|
46
54
|
...intents,
|
|
47
55
|
...claims,
|
|
48
56
|
...conversations,
|
|
49
57
|
publishDeliveryBinding: deliveryBindings.publishDeliveryBinding,
|
|
58
|
+
clearDeliveryBinding: deliveryBindings.clearDeliveryBinding,
|
|
50
59
|
listDeliveryBindings: deliveryBindings.listDeliveryBindings,
|
|
60
|
+
refreshDeliveryBinding: deliveryBindings.refreshDeliveryBinding,
|
|
51
61
|
...inbox,
|
|
52
62
|
...receipts,
|
|
53
63
|
...sync,
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import { effectiveCapabilities } from "@agents-can-communicate/adapter-sdk";
|
|
2
|
-
|
|
3
|
-
const PLATFORM = `${process.platform}-${process.arch}`;
|
|
4
1
|
const SAFE_ERRORS = new Set(["ambiguous_recipient_sessions", "delivery_disabled",
|
|
5
2
|
"recipient_busy", "recipient_unavailable", "transport_error", "transport_rejected",
|
|
6
3
|
"unsupported_client_version"]);
|
|
@@ -11,8 +8,23 @@ const adaptersById = adapters => adapters instanceof Map
|
|
|
11
8
|
: new Map((Array.isArray(adapters) ? adapters : Object.values(adapters ?? {}))
|
|
12
9
|
.map(adapter => [adapter.id, adapter]));
|
|
13
10
|
|
|
11
|
+
// Everything that closes or advances a conversation is actionable: a question
|
|
12
|
+
// or request asks for work, an answer or decision resolves it, a handoff
|
|
13
|
+
// transfers it. A note informs and waits for the next turn. Room messages
|
|
14
|
+
// have no recipient and are never offered live.
|
|
15
|
+
const ACTIONABLE = new Set(["question", "request", "answer", "decision", "handoff"]);
|
|
14
16
|
const permits = (policy, kind) => policy === "all"
|
|
15
|
-
|| (policy === "actionable" &&
|
|
17
|
+
|| (policy === "actionable" && ACTIONABLE.has(kind));
|
|
18
|
+
|
|
19
|
+
// Compatibility was decided twice already - at bootstrap by the probe and at
|
|
20
|
+
// SessionStart by the generation-bound handshake that published this binding.
|
|
21
|
+
// The router validates binding identity and the adapter's answer; it does not
|
|
22
|
+
// impose a third, exact-version rule that would reject a client the handshake
|
|
23
|
+
// admitted.
|
|
24
|
+
const liveCapable = (adapter, binding) => adapter !== undefined
|
|
25
|
+
&& adapter.capabilities?.delivery?.livePush === true
|
|
26
|
+
&& adapter.nativeDelivery !== undefined
|
|
27
|
+
&& binding.availableModes.includes("livePush");
|
|
16
28
|
|
|
17
29
|
const durable = (recipientParticipantId, errorCode) => ({ recipientParticipantId,
|
|
18
30
|
outcome: "queued", transport: "durable", errorCode });
|
|
@@ -57,22 +69,24 @@ export function createDeliveryRouter({ service, adapters, clock }) {
|
|
|
57
69
|
if (permitted.length === 0) return durable(participantId, "delivery_disabled");
|
|
58
70
|
const reachable = permitted.filter(binding => binding.availableModes.includes("livePush"));
|
|
59
71
|
if (reachable.length === 0) return durable(participantId, "recipient_unavailable");
|
|
60
|
-
const
|
|
72
|
+
const capable = reachable.map(binding => ({ binding,
|
|
61
73
|
adapter: registry.get(binding.adapterId) }))
|
|
62
|
-
.filter(({ binding, adapter }) => adapter
|
|
63
|
-
|
|
64
|
-
{ clientVersion: binding.clientVersion, platform: PLATFORM }).delivery.livePush);
|
|
65
|
-
if (certified.length === 0) {
|
|
74
|
+
.filter(({ binding, adapter }) => liveCapable(adapter, binding));
|
|
75
|
+
if (capable.length === 0) {
|
|
66
76
|
return durable(participantId, "unsupported_client_version");
|
|
67
77
|
}
|
|
68
|
-
if (
|
|
78
|
+
if (capable.length > 1) {
|
|
69
79
|
return durable(participantId, "ambiguous_recipient_sessions");
|
|
70
80
|
}
|
|
71
81
|
|
|
72
|
-
const { binding, adapter } =
|
|
82
|
+
const { binding, adapter } = capable[0];
|
|
73
83
|
let response;
|
|
74
84
|
try {
|
|
75
|
-
|
|
85
|
+
// The store root is this workspace's runtime dir; the adapter resolves its
|
|
86
|
+
// opaque endpoint id under it. Passed as data, never as a leak into core:
|
|
87
|
+
// the router does not read what the adapter does with it.
|
|
88
|
+
response = await adapter.offerMessage({ binding, message,
|
|
89
|
+
runtimeDir: service.store?.root });
|
|
76
90
|
} catch {
|
|
77
91
|
await recordFailure(binding, message, participantId, "live-adapter", "transport_error");
|
|
78
92
|
return durable(participantId, "transport_error");
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agents-can-communicate/hook-runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
|
-
".": "./src/runner.mjs"
|
|
7
|
+
".": "./src/runner.mjs",
|
|
8
|
+
"./client-pid": "./src/client-pid.mjs",
|
|
9
|
+
"./process-table": "./src/process-table.mjs"
|
|
8
10
|
},
|
|
9
11
|
"files": [
|
|
10
12
|
"src/"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { validateNativeHandshake } from "@agents-can-communicate/adapter-sdk";
|
|
2
|
+
import { EXIT } from "@agents-can-communicate/protocol";
|
|
3
|
+
|
|
4
|
+
// The hook side of native delivery: one bounded, fail-open attempt to bind
|
|
5
|
+
// this exact ACC session generation to the vendor session the adapter can
|
|
6
|
+
// reach, then a sanitized binding published for the router. The helper, not
|
|
7
|
+
// the adapter, supplies adapter id, client facts, session id, and generation;
|
|
8
|
+
// the adapter supplies only closed handshake facts. Nothing here throws into
|
|
9
|
+
// the hook path, and no endpoint reference ever leaves through the result.
|
|
10
|
+
|
|
11
|
+
export const LIVE_POLICIES = Object.freeze(["off", "actionable", "all"]);
|
|
12
|
+
export const NATIVE_BINDING_STATES = Object.freeze(["active", "off", "degraded", "unsupported"]);
|
|
13
|
+
const DEFAULT_TIMEOUT_MS = 750;
|
|
14
|
+
const DEFAULT_CADENCE_MS = 60_000;
|
|
15
|
+
|
|
16
|
+
// Reasons the static rule refused: the client will not change within the
|
|
17
|
+
// session, so the state is "unsupported" rather than a retryable "degraded".
|
|
18
|
+
const STATIC_REASONS = new Set(["native_delivery_unsupported", "platform_not_captured",
|
|
19
|
+
"version_unavailable", "prerelease_not_captured", "below_minimum_version",
|
|
20
|
+
"known_bad_version"]);
|
|
21
|
+
|
|
22
|
+
// Only the value a successful owned shell bootstrap exported counts; anything
|
|
23
|
+
// else - missing, blank, differently cased, a number - is off.
|
|
24
|
+
export function livePolicyFrom(env) {
|
|
25
|
+
const value = env?.ACC_NATIVE_DELIVERY_POLICY;
|
|
26
|
+
return LIVE_POLICIES.includes(value) ? value : "off";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const isPid = value => Number.isInteger(value) && value > 0;
|
|
30
|
+
|
|
31
|
+
export async function establishNativeBinding({ adapter, event, hookBinding, clientVersion,
|
|
32
|
+
platform, livePolicy, service, runtimeDir, clock,
|
|
33
|
+
timeoutMs = DEFAULT_TIMEOUT_MS, heartbeatCadenceMs = DEFAULT_CADENCE_MS }) {
|
|
34
|
+
const outcome = (state, reasonCode, modes = []) =>
|
|
35
|
+
Object.freeze({ state, reasonCode, modes: Object.freeze([...modes]) });
|
|
36
|
+
const sessionId = hookBinding?.accSessionId;
|
|
37
|
+
const generation = hookBinding?.generation;
|
|
38
|
+
if (typeof sessionId !== "string" || typeof generation !== "string") return outcome("off", null);
|
|
39
|
+
const policy = LIVE_POLICIES.includes(livePolicy) ? livePolicy : "off";
|
|
40
|
+
const clear = () => service.clearDeliveryBinding({ sessionId, generation }).catch(() => null);
|
|
41
|
+
if (policy === "off") {
|
|
42
|
+
await clear();
|
|
43
|
+
return outcome("off", null);
|
|
44
|
+
}
|
|
45
|
+
if (adapter?.nativeDelivery === undefined || typeof adapter.bindNativeSession !== "function") {
|
|
46
|
+
return outcome("unsupported", "native_delivery_unsupported");
|
|
47
|
+
}
|
|
48
|
+
const clientPid = hookBinding.clientPid;
|
|
49
|
+
if (!isPid(clientPid)) {
|
|
50
|
+
await clear();
|
|
51
|
+
return outcome("degraded", "client_process_unknown");
|
|
52
|
+
}
|
|
53
|
+
// Whatever this generation published before is retired first, so a failed
|
|
54
|
+
// re-handshake can never leave yesterday's endpoint reachable.
|
|
55
|
+
await clear();
|
|
56
|
+
let timer = null;
|
|
57
|
+
try {
|
|
58
|
+
const budget = Math.max(1, Math.floor(timeoutMs));
|
|
59
|
+
const handshake = await Promise.race([
|
|
60
|
+
adapter.bindNativeSession({ event, clientPid, clientVersion, runtimeDir, timeoutMs: budget }),
|
|
61
|
+
new Promise((_resolve, reject) => {
|
|
62
|
+
timer = setTimeout(() => reject(Object.assign(new Error("native handshake timed out"),
|
|
63
|
+
{ code: "ETIMEDOUT" })), budget);
|
|
64
|
+
}),
|
|
65
|
+
]);
|
|
66
|
+
const verdict = validateNativeHandshake(adapter, { clientVersion, platform, handshake });
|
|
67
|
+
if (!verdict.ok) {
|
|
68
|
+
return outcome(STATIC_REASONS.has(verdict.reasonCode) ? "unsupported" : "degraded",
|
|
69
|
+
verdict.reasonCode);
|
|
70
|
+
}
|
|
71
|
+
const now = Date.parse(clock.now());
|
|
72
|
+
const lease = Date.parse(verdict.leaseUntil);
|
|
73
|
+
if (!(lease > now)) return outcome("degraded", "handshake_failed");
|
|
74
|
+
const ceiling = now + 2 * heartbeatCadenceMs;
|
|
75
|
+
const leaseUntil = lease > ceiling ? new Date(ceiling).toISOString() : verdict.leaseUntil;
|
|
76
|
+
await service.publishDeliveryBinding({
|
|
77
|
+
sessionId, generation, adapterId: adapter.id, clientVersion,
|
|
78
|
+
availableModes: [...verdict.modes], livePolicy: policy,
|
|
79
|
+
opaqueEndpointRef: verdict.opaqueEndpointRef, leaseUntil,
|
|
80
|
+
});
|
|
81
|
+
return outcome("active", null, verdict.modes);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
await clear();
|
|
84
|
+
const reasonCode = error?.code === "ETIMEDOUT" ? "handshake_timeout"
|
|
85
|
+
: error?.code === EXIT.CONFLICT ? "session_generation_stale" : "handshake_failed";
|
|
86
|
+
return outcome("degraded", reasonCode);
|
|
87
|
+
} finally {
|
|
88
|
+
if (timer !== null) clearTimeout(timer);
|
|
89
|
+
}
|
|
90
|
+
}
|