agents-can-communicate 0.8.0 → 0.8.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/bin/entrypoints/acc-mcp.mjs +0 -1
- package/bin/entrypoints/acc.mjs +0 -1
- package/docs/ADAPTER_AUTHORING.md +36 -17
- package/docs/ARCHITECTURE.md +9 -6
- package/docs/CAPABILITIES.md +23 -13
- package/docs/CLI.md +6 -3
- package/docs/PROTOCOL.md +2 -2
- package/docs/RELEASING.md +6 -4
- package/docs/TROUBLESHOOTING.md +2 -2
- 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-antigravity/src/adapter.mjs +2 -3
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +5 -4
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-delivery.mjs +12 -17
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-endpoint.mjs +2 -2
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +3 -4
- package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +10 -13
- package/node_modules/@agents-can-communicate/adapter-codex/src/live-permissions.mjs +5 -5
- package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-host.mjs +46 -10
- package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance.mjs +5 -7
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +16 -12
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-endpoint.mjs +12 -2
- package/node_modules/@agents-can-communicate/adapter-codex/src/service-setup.mjs +26 -9
- package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +12 -0
- 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/certification.mjs +2 -2
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +3 -2
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +85 -77
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +4 -4
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +5 -6
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance.mjs +9 -15
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/pins.mjs +13 -4
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/receipts.mjs +17 -2
- package/node_modules/@agents-can-communicate/core/src/status.mjs +14 -2
- package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
- package/node_modules/@agents-can-communicate/delivery-router/src/refresh-binding.mjs +2 -2
- package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +9 -41
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/client-pid.mjs +30 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +3 -4
- package/node_modules/@agents-can-communicate/hook-runner/src/process-table.mjs +30 -14
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +3 -1
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/src/delivery-diagnostics.mjs +0 -2
- package/node_modules/@agents-can-communicate/installer/src/detect.mjs +3 -4
- 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
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import { CONTRACT_ID, FINGERPRINT, HANDSHAKE_KEYS, KNOWN_BAD_REASON, NATIVE_BINDING_MODES,
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
PLANNABLE_ACTIVATION_KINDS, PROBE_KEYS, TIMESTAMP, assertModes, assertReasonCode, closed,
|
|
3
|
+
compareStableVersions, deepFreeze, isPlainObject, isText, parseStableVersion, usage }
|
|
4
|
+
from "./native-vocabulary.mjs";
|
|
5
|
+
import { compareVersionOrder, versionOrder } from "./certification.mjs";
|
|
5
6
|
|
|
6
|
-
// The native-delivery compatibility contract: a
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// The native-delivery compatibility contract: a minimum that is a real passing
|
|
8
|
+
// capture, one or more anchors naming the captured protocol, an explicit
|
|
9
|
+
// denylist, and the activation kinds an adapter may ask the installer for.
|
|
10
|
+
// There is deliberately no maximum: a newer stable client is admitted only
|
|
10
11
|
// when a current read-only probe and a per-session handshake confirm the same
|
|
11
|
-
// protocol contract.
|
|
12
|
-
//
|
|
12
|
+
// protocol contract. There is no platform either: a capture records where a
|
|
13
|
+
// behaviour was observed, the minimum it establishes applies everywhere, and
|
|
14
|
+
// the probe and handshake decide on the machine in hand (see
|
|
15
|
+
// docs/design/2026-09-26-native-delivery-across-platforms.md). Certification
|
|
16
|
+
// evidence still governs every other capability; this rule is used for native
|
|
17
|
+
// live delivery alone.
|
|
13
18
|
|
|
14
|
-
export { NATIVE_ACTIVATION_KINDS, NATIVE_BINDING_MODES,
|
|
19
|
+
export { NATIVE_ACTIVATION_KINDS, NATIVE_BINDING_MODES, NATIVE_REASON_CODES,
|
|
15
20
|
PLANNABLE_ACTIVATION_KINDS, compareStableVersions, parseStableVersion } from "./native-vocabulary.mjs";
|
|
16
21
|
export { validateNativeActivationPlan } from "./native-activation.mjs";
|
|
17
22
|
|
|
@@ -29,8 +34,16 @@ export function validateNativeDeliveryContract(value, { certification, client })
|
|
|
29
34
|
// the next-turn hook, which records the offer itself once its stdout carried
|
|
30
35
|
// the body. Recording a wake as offered would hide the body from that hook.
|
|
31
36
|
const offerKind = Object.hasOwn(value ?? {}, "offerKind") ? value.offerKind : "message";
|
|
37
|
+
// The per-platform shape 0.8.0 and earlier declared. Refused by name: an
|
|
38
|
+
// adapter built against that SDK should learn what replaced it, not fail on
|
|
39
|
+
// a generic unknown field.
|
|
40
|
+
if (Object.hasOwn(value ?? {}, "minimumByPlatform")) {
|
|
41
|
+
usage("nativeDelivery.minimumByPlatform is no longer declared; a capture's minimum applies on "
|
|
42
|
+
+ "every platform, so declare nativeDelivery.minimum instead "
|
|
43
|
+
+ "(docs/design/2026-09-26-native-delivery-across-platforms.md)");
|
|
44
|
+
}
|
|
32
45
|
closed({ ...value, policySource, offerKind },
|
|
33
|
-
["
|
|
46
|
+
["minimum", "anchors", "knownBad", "activationKinds", "policySource", "offerKind"],
|
|
34
47
|
"nativeDelivery");
|
|
35
48
|
if (policySource !== "installation-record") {
|
|
36
49
|
usage("nativeDelivery.policySource must be installation-record");
|
|
@@ -38,48 +51,43 @@ export function validateNativeDeliveryContract(value, { certification, client })
|
|
|
38
51
|
if (!["message", "wake"].includes(offerKind)) {
|
|
39
52
|
usage("nativeDelivery.offerKind must be message or wake");
|
|
40
53
|
}
|
|
41
|
-
const
|
|
42
|
-
if (
|
|
43
|
-
usage("nativeDelivery.
|
|
44
|
-
}
|
|
45
|
-
for (const [platform, version] of Object.entries(minimums)) {
|
|
46
|
-
if (!NATIVE_PLATFORMS.includes(platform)) {
|
|
47
|
-
usage(`nativeDelivery.minimumByPlatform names an unknown platform ${platform}`);
|
|
48
|
-
}
|
|
49
|
-
if (parseStableVersion(version) === null) {
|
|
50
|
-
usage(`nativeDelivery.minimumByPlatform ${platform} must be a stable version`);
|
|
51
|
-
}
|
|
54
|
+
const minimum = value.minimum;
|
|
55
|
+
if (parseStableVersion(minimum) === null) {
|
|
56
|
+
usage("nativeDelivery.minimum must be a stable version");
|
|
52
57
|
}
|
|
53
58
|
if (!Array.isArray(value.anchors) || value.anchors.length === 0) {
|
|
54
59
|
usage("nativeDelivery.anchors must name at least one passing capture");
|
|
55
60
|
}
|
|
56
61
|
const anchors = value.anchors.map((anchor, index) => {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
62
|
+
if (Object.hasOwn(anchor ?? {}, "platform")) {
|
|
63
|
+
usage(`nativeDelivery anchor ${index} names a platform; the platform a capture was taken on `
|
|
64
|
+
+ "is recorded by its evidence, and the anchor names the version alone "
|
|
65
|
+
+ "(docs/design/2026-09-26-native-delivery-across-platforms.md)");
|
|
66
|
+
}
|
|
67
|
+
closed(anchor, ["version", "protocolContract"], "nativeDelivery anchor");
|
|
68
|
+
if (parseStableVersion(anchor.version) === null) {
|
|
69
|
+
usage(`nativeDelivery anchor ${index} version must be a stable version`);
|
|
61
70
|
}
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
|
|
71
|
+
if (compareStableVersions(anchor.version, minimum) < 0) {
|
|
72
|
+
usage(`nativeDelivery anchor ${index} version ${anchor.version} is below the minimum ${minimum}: `
|
|
73
|
+
+ "the minimum must be the first passing capture");
|
|
65
74
|
}
|
|
66
75
|
if (!isText(anchor.protocolContract) || !CONTRACT_ID.test(anchor.protocolContract)) {
|
|
67
76
|
usage(`nativeDelivery anchor ${index} protocolContract must be a closed identifier`);
|
|
68
77
|
}
|
|
78
|
+
// Where the capture was taken is provenance the evidence row keeps; the
|
|
79
|
+
// anchor is proven by a passing capture of this version on any platform.
|
|
69
80
|
const proven = (certification?.evidence ?? []).some(item => item.result === "pass"
|
|
70
81
|
&& item.capability === "delivery.livePush" && item.client === client
|
|
71
|
-
&& item.version === anchor.version
|
|
82
|
+
&& item.version === anchor.version);
|
|
72
83
|
if (!proven) {
|
|
73
|
-
usage(`nativeDelivery anchor ${anchor.version}
|
|
74
|
-
|
|
84
|
+
usage(`nativeDelivery anchor ${anchor.version} has no passing delivery.livePush certification`,
|
|
85
|
+
{ anchor });
|
|
75
86
|
}
|
|
76
87
|
return { ...anchor };
|
|
77
88
|
});
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
usage(`nativeDelivery minimum ${minimum} on ${platform} must be the first passing capture: `
|
|
81
|
-
+ "no anchor matches it");
|
|
82
|
-
}
|
|
89
|
+
if (!anchors.some(anchor => compareStableVersions(anchor.version, minimum) === 0)) {
|
|
90
|
+
usage(`nativeDelivery minimum ${minimum} must be the first passing capture: no anchor matches it`);
|
|
83
91
|
}
|
|
84
92
|
if (!Array.isArray(value.knownBad)) usage("nativeDelivery.knownBad must be an array");
|
|
85
93
|
const knownBad = value.knownBad.map(entry => {
|
|
@@ -104,24 +112,29 @@ export function validateNativeDeliveryContract(value, { certification, client })
|
|
|
104
112
|
|| kinds.some(kind => !PLANNABLE_ACTIVATION_KINDS.includes(kind))) {
|
|
105
113
|
usage(`nativeDelivery.activationKinds must be unique entries of ${PLANNABLE_ACTIVATION_KINDS.join(", ")}`);
|
|
106
114
|
}
|
|
107
|
-
return deepFreeze({
|
|
108
|
-
|
|
115
|
+
return deepFreeze({ minimum, anchors, knownBad, activationKinds: [...kinds], policySource,
|
|
116
|
+
offerKind });
|
|
109
117
|
}
|
|
110
118
|
|
|
111
|
-
|
|
119
|
+
// `order` is the client's release triple; the denylist names stable versions,
|
|
120
|
+
// so a prerelease of a denylisted release is on it too.
|
|
121
|
+
function knownBadHit(contract, order) {
|
|
122
|
+
const hit = (left, right) => compareVersionOrder(left, versionOrder(right));
|
|
112
123
|
return contract.knownBad.some(entry => (Object.hasOwn(entry, "version")
|
|
113
|
-
?
|
|
114
|
-
:
|
|
124
|
+
? hit(order, entry.version) === 0
|
|
125
|
+
: hit(order, entry.from) >= 0 && hit(order, entry.to) <= 0));
|
|
115
126
|
}
|
|
116
127
|
|
|
117
128
|
// Judges one reported client version against an adapter's captured
|
|
118
|
-
// native-delivery contract: is the
|
|
119
|
-
//
|
|
129
|
+
// native-delivery contract: is the version at or above the captured minimum,
|
|
130
|
+
// and is it not on the denylist. Returns
|
|
120
131
|
// { reasonCode, minimumVersion, protocolContract }; reasonCode is null when
|
|
121
132
|
// the version satisfies the contract, otherwise one of
|
|
122
|
-
// "native_delivery_unsupported", "
|
|
123
|
-
//
|
|
124
|
-
//
|
|
133
|
+
// "native_delivery_unsupported", "version_unavailable", "below_minimum_version",
|
|
134
|
+
// or "known_bad_version". The platform the caller runs on plays no part: the
|
|
135
|
+
// minimum was captured somewhere and applies everywhere. A prerelease is
|
|
136
|
+
// judged by its release triple, as hook capabilities are: what admits it is
|
|
137
|
+
// the protocol contract the probe or handshake reports, not the suffix.
|
|
125
138
|
//
|
|
126
139
|
// This is the static half of the rule only: it never contacts the client, so
|
|
127
140
|
// passing here proves nothing about whether a live probe or handshake
|
|
@@ -130,38 +143,33 @@ function knownBadHit(contract, version) {
|
|
|
130
143
|
// validateNativeHandshake below, and the delivery router's own offer check,
|
|
131
144
|
// which uses this function alone because a response already carries no probe
|
|
132
145
|
// or handshake shape to check further).
|
|
133
|
-
export function evaluateVersionContract(adapter, { clientVersion
|
|
146
|
+
export function evaluateVersionContract(adapter, { clientVersion } = {}) {
|
|
134
147
|
const contract = adapter?.nativeDelivery;
|
|
135
|
-
|
|
136
|
-
// contract" as a missing one, and reading `contract.minimumByPlatform` off it
|
|
137
|
-
// threw the very TypeError the guard below exists to prevent. defineAdapter
|
|
138
|
-
// cannot produce that shape; a hand-built registry entry can.
|
|
139
|
-
if (contract == null) {
|
|
140
|
-
return { reasonCode: "native_delivery_unsupported", minimumVersion: null, protocolContract: null };
|
|
141
|
-
}
|
|
142
|
-
const uncaptured = { reasonCode: "platform_not_captured", minimumVersion: null,
|
|
148
|
+
const unsupported = { reasonCode: "native_delivery_unsupported", minimumVersion: null,
|
|
143
149
|
protocolContract: null };
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
150
|
+
// `== null` rather than `=== undefined`: a null declaration is as much "no
|
|
151
|
+
// contract" as a missing one, and reading `contract.minimum` off it threw
|
|
152
|
+
// the very TypeError the guard below exists to prevent. defineAdapter cannot
|
|
153
|
+
// produce that shape; a hand-built registry entry can.
|
|
154
|
+
if (contract == null) return unsupported;
|
|
155
|
+
// validateNativeDeliveryContract guarantees a minimum and a matching anchor,
|
|
156
|
+
// but this function is also handed adapter objects that never went through
|
|
157
|
+
// it. A declaration missing either half has captured nothing, which is a
|
|
158
|
+
// closed answer - not a TypeError raised deep inside a delivery offer, far
|
|
159
|
+
// from the declaration that caused it. Every other entry point below
|
|
160
|
+
// already answers malformed input this way.
|
|
161
|
+
const minimumVersion = isText(contract.minimum) ? contract.minimum : null;
|
|
162
|
+
if (minimumVersion === null) return unsupported;
|
|
153
163
|
const anchor = (Array.isArray(contract.anchors) ? contract.anchors : [])
|
|
154
|
-
.find(item => item
|
|
155
|
-
if (anchor === undefined) return
|
|
164
|
+
.find(item => item?.version === minimumVersion);
|
|
165
|
+
if (anchor === undefined) return unsupported;
|
|
156
166
|
const facts = { minimumVersion, protocolContract: anchor.protocolContract };
|
|
157
|
-
|
|
158
|
-
if (
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
if (compareStableVersions(clientVersion, minimumVersion) < 0) {
|
|
167
|
+
const order = versionOrder(clientVersion);
|
|
168
|
+
if (order === null) return { ...facts, reasonCode: "version_unavailable" };
|
|
169
|
+
if (compareVersionOrder(order, versionOrder(minimumVersion)) < 0) {
|
|
162
170
|
return { ...facts, reasonCode: "below_minimum_version" };
|
|
163
171
|
}
|
|
164
|
-
if (knownBadHit(contract,
|
|
172
|
+
if (knownBadHit(contract, order)) return { ...facts, reasonCode: "known_bad_version" };
|
|
165
173
|
return { ...facts, reasonCode: null };
|
|
166
174
|
}
|
|
167
175
|
|
|
@@ -184,13 +192,13 @@ function validateNativeProbe(probe) {
|
|
|
184
192
|
return probe;
|
|
185
193
|
}
|
|
186
194
|
|
|
187
|
-
export function evaluateNativeEligibility(adapter, { clientVersion,
|
|
195
|
+
export function evaluateNativeEligibility(adapter, { clientVersion, probe }) {
|
|
188
196
|
// The probe names the process that will serve the delivery. Judging the
|
|
189
197
|
// detected binary instead refuses a service that satisfies the contract.
|
|
190
198
|
// Only the version is read here; the shape is validated where it always was,
|
|
191
199
|
// so a malformed probe still returns a closed result rather than throwing.
|
|
192
200
|
const serving = isText(probe?.clientVersion) ? probe.clientVersion : clientVersion;
|
|
193
|
-
const rule = evaluateVersionContract(adapter, { clientVersion: serving
|
|
201
|
+
const rule = evaluateVersionContract(adapter, { clientVersion: serving });
|
|
194
202
|
const base = { eligible: false, reasonCode: null, minimumVersion: rule.minimumVersion,
|
|
195
203
|
protocolContract: rule.protocolContract, modes: [] };
|
|
196
204
|
const closedResult = reasonCode => deepFreeze({ ...base, reasonCode });
|
|
@@ -242,13 +250,13 @@ function validateNativeHandshakeShape(handshake) {
|
|
|
242
250
|
// the two records disagreed on every machine where a daemon had updated under
|
|
243
251
|
// its CLI, which is exactly the case the release exists to support. A refused
|
|
244
252
|
// verdict admits nothing and carries null.
|
|
245
|
-
export function validateNativeHandshake(adapter, { clientVersion,
|
|
253
|
+
export function validateNativeHandshake(adapter, { clientVersion, handshake }) {
|
|
246
254
|
// Same rationale as the probe: the handshake names the session that will
|
|
247
255
|
// actually serve, so the static rule is judged against that version. Only
|
|
248
256
|
// the version is read here; the shape is validated where it always was, so
|
|
249
257
|
// a malformed handshake still returns a closed result rather than throwing.
|
|
250
258
|
const serving = isText(handshake?.clientVersion) ? handshake.clientVersion : clientVersion;
|
|
251
|
-
const rule = evaluateVersionContract(adapter, { clientVersion: serving
|
|
259
|
+
const rule = evaluateVersionContract(adapter, { clientVersion: serving });
|
|
252
260
|
const base = { ok: false, reasonCode: null, clientVersion: null,
|
|
253
261
|
protocolContract: rule.protocolContract, modes: [],
|
|
254
262
|
opaqueEndpointRef: null, leaseUntil: null };
|
|
@@ -18,12 +18,12 @@ export const NATIVE_ACTIVATION_KINDS = Object.freeze([
|
|
|
18
18
|
// The kinds an adapter may still ask for.
|
|
19
19
|
export const PLANNABLE_ACTIVATION_KINDS = Object.freeze(["native-config", "native-service"]);
|
|
20
20
|
|
|
21
|
-
export const NATIVE_PLATFORMS = Object.freeze([
|
|
22
|
-
"darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "win32-x64",
|
|
23
|
-
]);
|
|
24
|
-
|
|
25
21
|
// Every reason a client is not live, closed so doctor, hooks, and docs share
|
|
26
22
|
// one vocabulary and vendor strings never leak through it.
|
|
23
|
+
// "platform_not_captured" and "prerelease_not_captured" are historical: 0.8.0
|
|
24
|
+
// and earlier refused a platform no capture named and any prerelease suffix,
|
|
25
|
+
// and their native-attempt records carry both. Nothing produces either any
|
|
26
|
+
// more (docs/design/2026-09-26-native-delivery-across-platforms.md).
|
|
27
27
|
export const NATIVE_REASON_CODES = Object.freeze([
|
|
28
28
|
"native_delivery_unsupported", "platform_not_captured", "version_unavailable",
|
|
29
29
|
"prerelease_not_captured", "below_minimum_version", "known_bad_version",
|
|
@@ -200,12 +200,11 @@ export function describeStatus(status) {
|
|
|
200
200
|
const offered = status.counts.unretrieved?.offered ?? 0;
|
|
201
201
|
const withOffers = offered === 0 ? line : `${line}; ${offered} offered, not retrieved`;
|
|
202
202
|
// Status lists a participant with no open session only while messages wait
|
|
203
|
-
// for it; each is named here, with the client's reason when it gave one.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
});
|
|
203
|
+
// for it; each is named here, with the client's reason when it gave one. The
|
|
204
|
+
// count is what its next turn would still show, never an offer already made.
|
|
205
|
+
const waiting = (status.participants ?? [])
|
|
206
|
+
.filter(item => item.presence === "offline" && (item.waiting ?? 0) > 0)
|
|
207
|
+
.map(item => `${item.participantId} (${item.endReason === "clear" ? "cleared, " : ""}${item.waiting})`);
|
|
209
208
|
return waiting.length === 0 ? withOffers
|
|
210
209
|
: `${withOffers}; waiting for a closed session: ${waiting.join(", ")}`;
|
|
211
210
|
}
|
|
@@ -31,24 +31,18 @@ export function maintenanceContext(control, env) {
|
|
|
31
31
|
* untouched, so a genuinely stale or unbound service is still selected.
|
|
32
32
|
*
|
|
33
33
|
* The contract answers two different questions with one field, and reading
|
|
34
|
-
* every non-null code as a refusal conflated them. "
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
|
|
41
|
-
* exactly the version its CLI is running. That is the complaint this work
|
|
42
|
-
* began from, reproduced on every machine but one. With no opinion to act on,
|
|
43
|
-
* the decision falls back to the comparison that governed before this branch:
|
|
44
|
-
* the served version against the CLI's. */
|
|
45
|
-
const UNCAPTURED = new Set(["platform_not_captured", "native_delivery_unsupported"]);
|
|
34
|
+
* every non-null code as a refusal conflated them. "native_delivery_unsupported"
|
|
35
|
+
* is not a verdict on any version: it is an adapter that declares no native
|
|
36
|
+
* delivery at all. With no opinion to act on, the decision falls back to the
|
|
37
|
+
* comparison that governed before the contract existed: the served version
|
|
38
|
+
* against the CLI's. A contract captured on one platform judges the daemon on
|
|
39
|
+
* every platform, so the host this process runs on is not asked. */
|
|
40
|
+
const NO_CONTRACT = "native_delivery_unsupported";
|
|
46
41
|
|
|
47
42
|
const servingVersionIsContrary = (adapter, snapshot) => {
|
|
48
|
-
const { reasonCode } = evaluateVersionContract(adapter,
|
|
49
|
-
{ clientVersion: snapshot.serverVersion, platform: HOST_PLATFORM });
|
|
43
|
+
const { reasonCode } = evaluateVersionContract(adapter, { clientVersion: snapshot.serverVersion });
|
|
50
44
|
if (reasonCode === null) return false;
|
|
51
|
-
if (
|
|
45
|
+
if (reasonCode !== NO_CONTRACT) return true;
|
|
52
46
|
return snapshot.serverVersion !== snapshot.cliVersion;
|
|
53
47
|
};
|
|
54
48
|
|
|
@@ -9,6 +9,7 @@ import { canonicalManagerRoot, managedDirectory, readManagedJson, syncDirectory,
|
|
|
9
9
|
writeManagedJson } from "./state.mjs";
|
|
10
10
|
|
|
11
11
|
const SCHEMA_VERSION = 1;
|
|
12
|
+
const isPid = value => Number.isSafeInteger(value) && value > 0;
|
|
12
13
|
|
|
13
14
|
// Same hashing rule as session bindings: a foreign harness id never selects
|
|
14
15
|
// which file is written.
|
|
@@ -30,13 +31,19 @@ export const hookEntrypointFor = root => path.join(root, "bin", "entrypoints", "
|
|
|
30
31
|
// once, on every entry point - matching every other managed-runtime module -
|
|
31
32
|
// is what makes "the root the writer used" and "the root the reader used"
|
|
32
33
|
// the same derivation instead of two that merely happen to agree today.
|
|
34
|
+
//
|
|
35
|
+
// A pin holds its generation until its client is confirmed dead, so a pin with
|
|
36
|
+
// no client pid could never be released and kept its generation forever. A
|
|
37
|
+
// session whose client cannot be found goes unpinned instead, and its hooks run
|
|
38
|
+
// the active generation - the fallback every unresolvable pin already gets.
|
|
33
39
|
export async function writePin({ root, harnessSessionId, runtimeRoot, version, storeVersion,
|
|
34
40
|
clientPid }) {
|
|
41
|
+
if (!isPid(clientPid)) return;
|
|
35
42
|
root = await canonicalManagerRoot(root);
|
|
36
43
|
await managedDirectory(path.join(root, "pins"), { create: true });
|
|
37
44
|
await writeManagedJson(fileFor(root, harnessSessionId), { schemaVersion: SCHEMA_VERSION,
|
|
38
45
|
harnessSessionId, runtimeRoot, version, storeVersion: storeVersion ?? null,
|
|
39
|
-
clientPid
|
|
46
|
+
clientPid, createdAt: new Date().toISOString() });
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
export async function readPin({ root, harnessSessionId }) {
|
|
@@ -105,7 +112,9 @@ export async function resolvePinnedGeneration({ root, harnessSessionId, active }
|
|
|
105
112
|
}
|
|
106
113
|
|
|
107
114
|
/** A client that exits without SessionEnd leaves its pin behind. Admission
|
|
108
|
-
* already reaps dead leases; pins follow the same confirmed-death rule.
|
|
115
|
+
* already reaps dead leases; pins follow the same confirmed-death rule. A pin
|
|
116
|
+
* that names no client, which 0.8.0 and earlier wrote when the pid was not yet
|
|
117
|
+
* known, is reaped outright: nothing could ever prove its session over. */
|
|
109
118
|
export async function reapPins({ root, pidIsAlive = defaultPidIsAlive } = {}) {
|
|
110
119
|
root = await canonicalManagerRoot(root);
|
|
111
120
|
const directory = path.join(root, "pins");
|
|
@@ -115,8 +124,8 @@ export async function reapPins({ root, pidIsAlive = defaultPidIsAlive } = {}) {
|
|
|
115
124
|
if (!name.endsWith(".json")) continue;
|
|
116
125
|
const file = path.join(directory, name);
|
|
117
126
|
const record = await readManagedJson(file).catch(() => null);
|
|
118
|
-
|
|
119
|
-
if (
|
|
127
|
+
if (record?.schemaVersion !== SCHEMA_VERSION) continue; // Unknown shape: an unknown holder.
|
|
128
|
+
if (!isPid(record.clientPid) || await confirmedDead(record.clientPid, pidIsAlive)) {
|
|
120
129
|
await rm(file, { force: true });
|
|
121
130
|
removed = true;
|
|
122
131
|
}
|
|
@@ -34,11 +34,26 @@ export function offerFacts(receipt) {
|
|
|
34
34
|
export function dueForRepeat(message, receipt, now) {
|
|
35
35
|
const offer = offerFacts(receipt);
|
|
36
36
|
return message != null && message.toParticipantIds.length > 0
|
|
37
|
-
&& receipt?.state === "offered" && offer !== null
|
|
38
|
-
&& offer.transport !== "next-turn" && offer.repeatedAt === null
|
|
37
|
+
&& receipt?.state === "offered" && offer !== null && liveAndUnrepeated(offer)
|
|
39
38
|
&& Date.parse(now) - Date.parse(offer.at) >= REPEAT_OFFER_AFTER_MS;
|
|
40
39
|
}
|
|
41
40
|
|
|
41
|
+
// A live transport took the bytes, and the next turn has not shown them again.
|
|
42
|
+
const liveAndUnrepeated = offer => offer.transport !== "next-turn" && offer.repeatedAt === null;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Whether the recipient's next turn could still show this receipt's message:
|
|
46
|
+
* queued, or offered live and not yet shown again. A next-turn offer already
|
|
47
|
+
* put the body in the recipient's context. An offer an older ACC recorded
|
|
48
|
+
* without facts is unknown, so it counts as still to be shown.
|
|
49
|
+
*/
|
|
50
|
+
export function awaitsNextTurn(receipt) {
|
|
51
|
+
if (receipt?.state === "queued") return true;
|
|
52
|
+
if (receipt?.state !== "offered") return false;
|
|
53
|
+
const offer = offerFacts(receipt);
|
|
54
|
+
return offer === null || liveAndUnrepeated(offer);
|
|
55
|
+
}
|
|
56
|
+
|
|
42
57
|
export const SAFE_OFFER_ERROR_CODES = Object.freeze([
|
|
43
58
|
"ambiguous_recipient_sessions",
|
|
44
59
|
"delivery_disabled",
|
|
@@ -2,6 +2,7 @@ import { classifySessionPresence } from "./sessions.mjs";
|
|
|
2
2
|
import { computeAttention } from "./attention.mjs";
|
|
3
3
|
import { isCurrentIntent } from "./intents.mjs";
|
|
4
4
|
import { decisionView, isCurrentDecision } from "./decision-state.mjs";
|
|
5
|
+
import { awaitsNextTurn } from "./receipts.mjs";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Protection level, reported from what is actually enforceable.
|
|
@@ -96,6 +97,10 @@ export function createStatusService(ports, sessions, deliveryBindings) {
|
|
|
96
97
|
.filter(isCurrentDecision).map(message => message.messageId));
|
|
97
98
|
const unretrieved = new Map();
|
|
98
99
|
const tally = key => unretrieved.get(key) ?? { queued: 0, offered: 0 };
|
|
100
|
+
// Narrower than unretrieved: what the recipient's next turn would still
|
|
101
|
+
// show. An offer the next turn already made is unretrieved, but nothing
|
|
102
|
+
// will ever reach the recipient for it again.
|
|
103
|
+
const waiting = new Map();
|
|
99
104
|
for (const receipt of snapshot.receipts) {
|
|
100
105
|
if (!current.has(receipt.messageId)
|
|
101
106
|
|| !["queued", "offered"].includes(receipt.state)) continue;
|
|
@@ -103,6 +108,10 @@ export function createStatusService(ports, sessions, deliveryBindings) {
|
|
|
103
108
|
const counts = tally(key);
|
|
104
109
|
unretrieved.set(key, { ...counts, [receipt.state]: counts[receipt.state] + 1 });
|
|
105
110
|
}
|
|
111
|
+
if (awaitsNextTurn(receipt)) {
|
|
112
|
+
waiting.set(receipt.recipientParticipantId,
|
|
113
|
+
(waiting.get(receipt.recipientParticipantId) ?? 0) + 1);
|
|
114
|
+
}
|
|
106
115
|
}
|
|
107
116
|
|
|
108
117
|
// A participant with no live session but messages waiting for it stays in
|
|
@@ -116,7 +125,7 @@ export function createStatusService(ports, sessions, deliveryBindings) {
|
|
|
116
125
|
}
|
|
117
126
|
}
|
|
118
127
|
const awaited = new Set([...latest.values()].filter(session => !liveParticipants.has(session.participantId)
|
|
119
|
-
&& (
|
|
128
|
+
&& (waiting.get(session.participantId) ?? 0) > 0)
|
|
120
129
|
.map(session => session.sessionId));
|
|
121
130
|
|
|
122
131
|
return {
|
|
@@ -149,7 +158,10 @@ export function createStatusService(ports, sessions, deliveryBindings) {
|
|
|
149
158
|
unretrieved: tally(session.participantId),
|
|
150
159
|
// The client's reason when it ended the session, such as Claude Code's
|
|
151
160
|
// "clear". Kept in the record's extensions, so it is null for 0.7.x.
|
|
152
|
-
|
|
161
|
+
// And how many messages its next turn would still show, the count that
|
|
162
|
+
// keeps it listed.
|
|
163
|
+
...(presence === "offline" ? { endReason: session.extensions?.endReason ?? null,
|
|
164
|
+
waiting: waiting.get(session.participantId) ?? 0 } : {}),
|
|
153
165
|
})),
|
|
154
166
|
// The owner is named twice on purpose. Every command that reaches a peer
|
|
155
167
|
// takes a participant id, so a claim that gave only a session id sent the
|
|
@@ -14,14 +14,14 @@ function sameBinding(current, expected, leaseUntil) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export async function refreshExpiredBinding({ service, adapter, binding, runtimeDir,
|
|
17
|
-
|
|
17
|
+
clock, timeoutMs = 750 }) {
|
|
18
18
|
if (typeof adapter?.refreshNativeSession !== "function") return false;
|
|
19
19
|
const now = Date.parse(clock.now());
|
|
20
20
|
if (!Number.isFinite(now)) return false;
|
|
21
21
|
try {
|
|
22
22
|
const handshake = await adapter.refreshNativeSession({ binding, runtimeDir, timeoutMs });
|
|
23
23
|
const validated = validateNativeHandshake(adapter, {
|
|
24
|
-
clientVersion: binding.clientVersion,
|
|
24
|
+
clientVersion: binding.clientVersion, handshake,
|
|
25
25
|
});
|
|
26
26
|
if (!validated.ok || validated.opaqueEndpointRef !== binding.opaqueEndpointRef) return false;
|
|
27
27
|
const requestedLease = Date.parse(validated.leaseUntil);
|
|
@@ -21,35 +21,12 @@ const LIVE_POLICIES = new Set(["off", "actionable", "all"]);
|
|
|
21
21
|
const permits = (policy, kind) => policy === "all"
|
|
22
22
|
|| (policy === "actionable" && ACTIONABLE.has(kind));
|
|
23
23
|
|
|
24
|
-
// A native-delivery contract is captured per platform, and the only platform a
|
|
25
|
-
// router can judge an offer against is the one it is running on - which is why
|
|
26
|
-
// both entrypoints pass exactly this value. Leaving the parameter optional made
|
|
27
|
-
// its absence silently disable every live offer: `undefined` reached the
|
|
28
|
-
// contract check as "platform_not_captured" and the offer was refused as an
|
|
29
|
-
// unsupported client version. Default it here instead, so a router that is
|
|
30
|
-
// handed nothing behaves as the host it runs on. Injection stays available for
|
|
31
|
-
// callers that need to judge against another platform.
|
|
32
|
-
const HOST_PLATFORM = `${process.platform}-${process.arch}`;
|
|
33
|
-
|
|
34
|
-
// evaluateVersionContract answers two different questions with one field.
|
|
35
|
-
// "below_minimum_version", "known_bad_version", "version_unavailable" and
|
|
36
|
-
// "prerelease_not_captured" are a capture reading the reported version and
|
|
37
|
-
// refusing it. "platform_not_captured" is the opposite: a complete, valid
|
|
38
|
-
// declaration that records nothing for the platform this router runs on. That
|
|
39
|
-
// is not a malformed adapter, it is the ordinary shape of every shipped
|
|
40
|
-
// adapter away from darwin-arm64 - the only platform any of them has captured
|
|
41
|
-
// - so reading it as a refusal disables live delivery on Linux entirely.
|
|
42
|
-
//
|
|
43
|
-
// "native_delivery_unsupported" is absent deliberately: liveCapable already
|
|
44
|
-
// required a nativeDelivery declaration before an offer was attempted, so the
|
|
45
|
-
// contract check is never handed an adapter without one.
|
|
46
|
-
const UNCAPTURED_PLATFORM = "platform_not_captured";
|
|
47
|
-
|
|
48
24
|
// Compatibility was decided twice already - at bootstrap by the probe and at
|
|
49
25
|
// SessionStart by the generation-bound handshake that published this binding.
|
|
50
26
|
// The router validates binding identity and the adapter's answer; it does not
|
|
51
27
|
// impose a third, exact-version rule that would reject a client the handshake
|
|
52
|
-
// admitted.
|
|
28
|
+
// admitted. The host it runs on is not asked either: a contract's minimum was
|
|
29
|
+
// captured somewhere and applies everywhere.
|
|
53
30
|
const liveCapable = (adapter, binding) => adapter !== undefined
|
|
54
31
|
&& adapter.capabilities?.delivery?.livePush === true
|
|
55
32
|
&& adapter.nativeDelivery !== undefined
|
|
@@ -68,8 +45,7 @@ function safeTransport(value, opaqueEndpointRef) {
|
|
|
68
45
|
return opaqueEndpointRef === "live-adapter" ? "native-live" : "live-adapter";
|
|
69
46
|
}
|
|
70
47
|
|
|
71
|
-
export function createDeliveryRouter({ service, adapters, clock,
|
|
72
|
-
readLivePolicy }) {
|
|
48
|
+
export function createDeliveryRouter({ service, adapters, clock, readLivePolicy }) {
|
|
73
49
|
const registry = adaptersById(adapters);
|
|
74
50
|
|
|
75
51
|
// Read at offer time, not from the binding: consent withdrawn after a session
|
|
@@ -139,7 +115,7 @@ export function createDeliveryRouter({ service, adapters, clock, platform = HOST
|
|
|
139
115
|
const { adapter } = capable[0];
|
|
140
116
|
if (Date.parse(binding.leaseUntil) <= Date.parse(now)) {
|
|
141
117
|
const refreshed = await refreshExpiredBinding({ service, adapter, binding,
|
|
142
|
-
runtimeDir: service.store?.root,
|
|
118
|
+
runtimeDir: service.store?.root, clock });
|
|
143
119
|
if (!refreshed) return durable(participantId, "recipient_unavailable");
|
|
144
120
|
const current = (await service.listDeliveryBindings({
|
|
145
121
|
participantId, now: clock.now() })).filter(item => item.sessionId === binding.sessionId
|
|
@@ -205,19 +181,11 @@ export function createDeliveryRouter({ service, adapters, clock, platform = HOST
|
|
|
205
181
|
// Compatibility was already decided at bind time; a serving version that
|
|
206
182
|
// still satisfies the adapter's captured contract keeps offering, even
|
|
207
183
|
// when it differs from the value recorded when the binding was created.
|
|
208
|
-
// Only a version below the captured minimum or on the denylist refuses
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
// does not make this unreachable - a complete, valid contract that names
|
|
214
|
-
// only darwin-arm64 says nothing about linux-x64, and that is every
|
|
215
|
-
// shipped adapter on Linux.
|
|
216
|
-
const versionRule = evaluateVersionContract(adapter, { clientVersion: response.clientVersion, platform });
|
|
217
|
-
const admitted = versionRule.reasonCode === null
|
|
218
|
-
|| (versionRule.reasonCode === UNCAPTURED_PLATFORM
|
|
219
|
-
&& response.clientVersion === binding.clientVersion);
|
|
220
|
-
if (!admitted) {
|
|
184
|
+
// Only a version below the captured minimum or on the denylist refuses,
|
|
185
|
+
// and a declaration that captured nothing refuses everything: a binding
|
|
186
|
+
// is not a capture.
|
|
187
|
+
const versionRule = evaluateVersionContract(adapter, { clientVersion: response.clientVersion });
|
|
188
|
+
if (versionRule.reasonCode !== null) {
|
|
221
189
|
await recordFailure(binding, message, participantId, transport,
|
|
222
190
|
"unsupported_client_version");
|
|
223
191
|
return durable(participantId, "unsupported_client_version");
|