agents-can-communicate 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -7
- 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 +34 -2
- package/docs/CAPABILITIES.md +25 -10
- package/docs/CLI.md +10 -3
- package/docs/CONFIGURATION.md +4 -0
- package/docs/HOW_IT_WORKS.md +277 -0
- package/docs/PROTOCOL.md +1 -1
- package/docs/RELEASING.md +7 -1
- package/docs/TROUBLESHOOTING.md +7 -1
- package/docs/index.md +3 -1
- 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 +4 -2
- 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 +4 -2
- 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 +4 -2
- 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 +4 -2
- 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 +4 -2
- 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 +103 -11
- package/node_modules/@agents-can-communicate/core/package.json +1 -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 +1 -1
- 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, describeActivation, 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,81 @@ 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
|
+
// One default-No question per eligible client, asked only after detection has
|
|
196
|
+
// finished and only when a person is at both ends of the terminal. It names
|
|
197
|
+
// the client, the mechanism, and the files or service it would touch, and says
|
|
198
|
+
// when the choice takes effect.
|
|
199
|
+
function questionFor(entry, context) {
|
|
200
|
+
const activation = { livePolicy: "actionable", shell: context.shell,
|
|
201
|
+
rcFile: rcFileFor(context.home, context.shell), shimDir: shimDirFor(context.stateRoot),
|
|
202
|
+
mechanisms: entry.nativeDelivery.activationPlan.mechanisms };
|
|
203
|
+
return [`Enable native live delivery for ${entry.displayName} ${entry.version}?`,
|
|
204
|
+
...describeActivation(activation).map(line => ` ${line}`),
|
|
205
|
+
" applies to newly started sessions; a new PATH block needs a new or reloaded shell"]
|
|
206
|
+
.join("\n");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Which live policy each selected client gets.
|
|
211
|
+
*
|
|
212
|
+
* An explicit --delivery applies uniformly and never prompts. Otherwise a
|
|
213
|
+
* recorded opt-in is kept as it was consented to, a fresh or never-activated
|
|
214
|
+
* client is off unless a person answers yes here, and a dry run or a
|
|
215
|
+
* non-interactive stdin/stdout makes no interactive choice at all.
|
|
216
|
+
*/
|
|
217
|
+
export async function decideDelivery({ options, detected, recorded, runtime, dryRun, context }) {
|
|
218
|
+
const explicit = options.delivery;
|
|
219
|
+
if (explicit !== undefined && !LIVE_POLICIES.includes(explicit)) {
|
|
220
|
+
throw new AccError(EXIT.USAGE, `unknown delivery policy: ${explicit}`, { delivery: explicit });
|
|
221
|
+
}
|
|
222
|
+
const recordedById = new Map(recorded.map(install => [install.adapterId, install]));
|
|
223
|
+
const deliveryByAdapter = {};
|
|
224
|
+
const asked = [];
|
|
225
|
+
let withheld = 0;
|
|
226
|
+
for (const entry of detected) {
|
|
227
|
+
const eligible = entry.nativeDelivery?.state === "eligible";
|
|
228
|
+
const previous = livePolicyOf(recordedById.get(entry.adapterId));
|
|
229
|
+
if (explicit !== undefined) {
|
|
230
|
+
deliveryByAdapter[entry.adapterId] = explicit;
|
|
231
|
+
} else if (previous !== "off") {
|
|
232
|
+
deliveryByAdapter[entry.adapterId] = previous;
|
|
233
|
+
} else if (!eligible || dryRun || !isInteractive(runtime)) {
|
|
234
|
+
deliveryByAdapter[entry.adapterId] = "off";
|
|
235
|
+
if (eligible) withheld += 1;
|
|
236
|
+
} else {
|
|
237
|
+
const yes = await runtime.confirm(questionFor(entry, context),
|
|
238
|
+
{ input: runtime.input, output: runtime.output }) === true;
|
|
239
|
+
deliveryByAdapter[entry.adapterId] = yes ? "actionable" : "off";
|
|
240
|
+
asked.push(entry.adapterId);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const notes = explicit === undefined && dryRun && withheld > 0
|
|
244
|
+
? ["interactive choices were not made: this preview keeps native delivery off for "
|
|
245
|
+
+ `${withheld} eligible client(s) without a recorded opt-in`]
|
|
246
|
+
: [];
|
|
247
|
+
return { deliveryByAdapter, asked, notes };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Said once, after the first PATH block is written: a running shell and a
|
|
251
|
+
// running client know nothing about it.
|
|
252
|
+
function reloadAdvice(operations) {
|
|
253
|
+
const appended = operations.filter(operation => operation.appendedRcBlock === true);
|
|
254
|
+
if (appended.length === 0) return [];
|
|
255
|
+
const rc = appended[0].nativeActivation?.rcFile ?? "your shell rc file";
|
|
256
|
+
return ["", `native delivery is wired for new sessions: open a new terminal (or reload ${rc}), `
|
|
257
|
+
+ "then start the client normally; a session already running keeps durable delivery"];
|
|
258
|
+
}
|
|
259
|
+
|
|
177
260
|
export async function runInstallCommand({ options, runtime, action = "install" }) {
|
|
178
261
|
const adapters = selectAdapters(options.adapter);
|
|
179
262
|
const home = options.home ?? runtime.env?.HOME ?? homedir();
|
|
180
263
|
const { data: dataHome } = platformPaths({ platform: runtime.platform,
|
|
181
264
|
env: runtime.env ?? {} });
|
|
182
|
-
const context = clientContext(home, path.join(dataHome, "acc")
|
|
265
|
+
const context = clientContext(home, path.join(dataHome, "acc"),
|
|
266
|
+
{ shell: shellOf(runtime.env), env: runtime.env ?? {} });
|
|
183
267
|
|
|
184
268
|
const detected = await detectInstallation({ adapters, context,
|
|
185
269
|
probeTimeoutMs: probeTimeout(runtime.env) });
|
|
@@ -205,20 +289,28 @@ export async function runInstallCommand({ options, runtime, action = "install" }
|
|
|
205
289
|
const requested = options.adapter === undefined
|
|
206
290
|
? []
|
|
207
291
|
: (Array.isArray(options.adapter) ? options.adapter : [options.adapter]);
|
|
292
|
+
// Decided only after detection, from the operator's explicit answer, the
|
|
293
|
+
// recorded consent, or a default of off; never from inference.
|
|
294
|
+
const decided = action === "install"
|
|
295
|
+
? await decideDelivery({ options, detected, recorded, runtime, dryRun, context })
|
|
296
|
+
: { deliveryByAdapter: {}, asked: [], notes: [] };
|
|
208
297
|
const plan = planInstallation({ adapters, detected, context, action, recorded,
|
|
209
298
|
accVersion, allowDowngrade: options.downgrade === true, requested,
|
|
210
|
-
|
|
299
|
+
deliveryByAdapter: decided.deliveryByAdapter });
|
|
211
300
|
const result = await applyPlan({ plan, adapters, context, dataHome, dryRun, accVersion });
|
|
212
301
|
|
|
213
302
|
const acted = actedOn(result);
|
|
214
303
|
if (dryRun) {
|
|
215
|
-
return { data: { ...result, plan, dataHome },
|
|
304
|
+
return { data: { ...result, plan, dataHome, deliveryByAdapter: decided.deliveryByAdapter },
|
|
216
305
|
text: [`would ${action}:`, ...plan.operations.flatMap(operation => operation.summary),
|
|
217
|
-
...plan.skipped.map(entry => `skip ${entry.adapterId}: ${entry.reason}`)
|
|
306
|
+
...plan.skipped.map(entry => `skip ${entry.adapterId}: ${entry.reason}`),
|
|
307
|
+
...decided.notes].join("\n") };
|
|
218
308
|
}
|
|
219
309
|
|
|
220
|
-
return { data: { ...result, plan, dataHome
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
310
|
+
return { data: { ...result, plan, dataHome, deliveryByAdapter: decided.deliveryByAdapter,
|
|
311
|
+
asked: decided.asked },
|
|
312
|
+
text: [describeOutcome({ action, acted, failed: result.failed,
|
|
313
|
+
skipped: plan.skipped, operations: result.operations, home }),
|
|
314
|
+
...reloadAdvice(result.operations)].join("\n"),
|
|
315
|
+
error: failureOf({ action, acted, failed: result.failed }) };
|
|
224
316
|
}
|
|
@@ -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.0",
|
|
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
|
+
}
|