agents-can-communicate 0.7.1 → 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/README.md +3 -1
- package/bin/entrypoints/acc-bootstrap.mjs +6 -48
- package/bin/entrypoints/acc-claude-channel.mjs +47 -157
- package/bin/entrypoints/acc-mcp.mjs +0 -1
- package/bin/entrypoints/acc.mjs +0 -1
- package/docs/ADAPTER_AUTHORING.md +74 -24
- package/docs/ARCHITECTURE.md +31 -18
- package/docs/CAPABILITIES.md +38 -25
- package/docs/CLI.md +51 -13
- package/docs/CONCEPTS.md +5 -4
- package/docs/CONFIGURATION.md +18 -11
- package/docs/GETTING_STARTED.md +15 -12
- package/docs/GLOSSARY.md +2 -1
- package/docs/HOW_IT_WORKS.md +8 -4
- package/docs/PROTOCOL.md +25 -6
- package/docs/RELEASING.md +8 -5
- package/docs/SECURITY_MODEL.md +14 -1
- package/docs/TROUBLESHOOTING.md +66 -29
- package/docs/UPGRADING.md +49 -8
- package/docs/WHY_ACC.md +5 -3
- 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/certification.json +14 -73
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/certification-provenance.json +29 -71
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.282-product-evidence.json +161 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.282.json +28 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/inbox-wake-2.1.282.json +44 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +5 -2
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +26 -30
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +6 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-contract.mjs +10 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-delivery.mjs +249 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-endpoint.mjs +166 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-registry.mjs +71 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-settings.mjs +109 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +25 -31
- 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/events.mjs +16 -3
- package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +4 -3
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +4 -3
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-activation.mjs +4 -14
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +104 -84
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +8 -4
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +8 -6
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +8 -23
- package/node_modules/@agents-can-communicate/cli/src/install-delivery-consent.mjs +3 -4
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +24 -3
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +7 -43
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +5 -3
- 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/cli/src/managed-runtime/refresh.mjs +3 -4
- package/node_modules/@agents-can-communicate/cli/src/native-delivery-status.mjs +2 -2
- 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/sessions.mjs +25 -2
- package/node_modules/@agents-can-communicate/core/src/status.mjs +31 -1
- 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 +33 -48
- 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-attempt.mjs +6 -8
- package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +27 -15
- 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 +4 -2
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +19 -5
- package/node_modules/@agents-can-communicate/installer/src/delivery-diagnostics.mjs +0 -2
- package/node_modules/@agents-can-communicate/installer/src/detect.mjs +6 -11
- package/node_modules/@agents-can-communicate/installer/src/index.mjs +4 -7
- package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +23 -61
- package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +7 -1
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +15 -5
- package/node_modules/@agents-can-communicate/installer/src/shell-bootstrap.mjs +10 -133
- 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/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +6 -1
- package/package.json +1 -1
- package/bin/acc-bootstrap.mjs +0 -7
- package/bin/acc-claude-channel.mjs +0 -10
- package/bin/entrypoints/claude-channel-binding.mjs +0 -46
- package/bin/entrypoints/claude-channel-stdio.mjs +0 -29
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.mcp.json +0 -8
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/channel.mjs +0 -381
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +0 -232
- package/node_modules/@agents-can-communicate/installer/src/bootstrap-runtime.mjs +0 -150
|
@@ -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
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import { applyPlan, detectInstallation, livePolicyOf, loadOwnership, planInstallation
|
|
2
|
+
import { applyPlan, detectInstallation, livePolicyOf, loadOwnership, planInstallation }
|
|
3
3
|
from "@agents-can-communicate/installer";
|
|
4
4
|
import { ALL_ADAPTERS, clientContext, probeTimeout } from "../install-command.mjs";
|
|
5
5
|
import { stablePaths, writeLaunchers } from "./launchers.mjs";
|
|
@@ -17,7 +17,7 @@ export async function prepareRefresh({ control, root, env = process.env, callerP
|
|
|
17
17
|
// session open across the update keeps running from the outgoing copy until it
|
|
18
18
|
// restarts. Naming it here keeps that copy; everything older than it goes.
|
|
19
19
|
const context = { ...clientContext(control.home, path.join(dataHome, "acc"),
|
|
20
|
-
{ env,
|
|
20
|
+
{ env, dataHome }), ...stablePaths(root),
|
|
21
21
|
keepPreviousVersion: control.active.version };
|
|
22
22
|
const recorded = (await loadOwnership({ dataHome })).installs;
|
|
23
23
|
const detected = await detectInstallation({ adapters, context, probeTimeoutMs: probeTimeout(env) });
|
|
@@ -38,8 +38,7 @@ export async function prepareRefresh({ control, root, env = process.env, callerP
|
|
|
38
38
|
}
|
|
39
39
|
return async () => {
|
|
40
40
|
await writeLaunchers(root, control.pending.root);
|
|
41
|
-
const result = await applyPlan({ plan, adapters, context, dataHome, accVersion: control.pending.version
|
|
42
|
-
activation: { bootstrap: context.bootstrap } });
|
|
41
|
+
const result = await applyPlan({ plan, adapters, context, dataHome, accVersion: control.pending.version });
|
|
43
42
|
return { ...result, failed: result.failed.map(failure => ({ ...failure,
|
|
44
43
|
paths: plan.operations.find(operation => operation.adapterId === failure.adapterId)
|
|
45
44
|
?.artifacts?.map(artifact => artifact.path).filter(file => typeof file === "string") ?? [],
|
|
@@ -34,11 +34,11 @@ export function nativeRemediation(entry) {
|
|
|
34
34
|
+ " # opt in to automatic peer requests; may spend tokens");
|
|
35
35
|
} else if (native.configured && native.activation === "missing") {
|
|
36
36
|
steps.push(`acc install --adapter ${entry.adapterId}`
|
|
37
|
-
+ " # complete the missing native
|
|
37
|
+
+ " # complete the missing native delivery setup");
|
|
38
38
|
} else if (native.configured && native.runtime !== "active"
|
|
39
39
|
&& !["needed", "blocked", "unsupported"].includes(service?.state)) {
|
|
40
40
|
steps.push(`${entry.displayName}: no verified live channel in this workspace; `
|
|
41
|
-
+ "
|
|
41
|
+
+ "start a new client session and check its integration prompts; "
|
|
42
42
|
+ "then run acc doctor here");
|
|
43
43
|
}
|
|
44
44
|
if (native.reasonCode === "native_endpoint_unavailable") {
|
|
@@ -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",
|
|
@@ -33,6 +33,9 @@ const ageBand = (session, age) =>
|
|
|
33
33
|
* @returns {"online" | "stale" | "offline"}
|
|
34
34
|
* @throws {AccError} EXIT.USAGE when pidIsAlive is not a function.
|
|
35
35
|
*/
|
|
36
|
+
// A client's end reason is a short word of its own ("clear", "logout").
|
|
37
|
+
const END_REASON = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
38
|
+
|
|
36
39
|
export function classifySessionPresence(session, now, pidIsAlive) {
|
|
37
40
|
// Required rather than defaulted. A probe that defaults to "everyone is
|
|
38
41
|
// alive" turns a forgotten call site into a check that silently passes, which
|
|
@@ -149,10 +152,14 @@ export function createSessionService(ports) {
|
|
|
149
152
|
}, { kinds: ["session"] });
|
|
150
153
|
}
|
|
151
154
|
|
|
152
|
-
async function closeSession({ sessionId, workspaceId, generation }) {
|
|
155
|
+
async function closeSession({ sessionId, workspaceId, generation, endReason = null }) {
|
|
156
|
+
// The client's own word for why, kept in extensions: an older ACC reading
|
|
157
|
+
// the same store skips them, and the session.closed event is unchanged.
|
|
158
|
+
const reason = typeof endReason === "string" && END_REASON.test(endReason) ? endReason : null;
|
|
153
159
|
const close = current => {
|
|
154
160
|
assertGeneration({ record: current }, generation, "close", workspaceId ?? store.workspaceId);
|
|
155
|
-
return { ...current, state: "closed", heartbeatAt: clock.now()
|
|
161
|
+
return { ...current, state: "closed", heartbeatAt: clock.now(),
|
|
162
|
+
...(reason === null ? {} : { extensions: { ...(current.extensions ?? {}), endReason: reason } }) };
|
|
156
163
|
};
|
|
157
164
|
let ephemeral = null;
|
|
158
165
|
await store.ephemeral.delete("session", sessionId, async current => {
|
|
@@ -179,6 +186,21 @@ export function createSessionService(ports) {
|
|
|
179
186
|
}, { kinds: ["session"] });
|
|
180
187
|
}
|
|
181
188
|
|
|
189
|
+
/** The participant's most recent session, open or closed, or null. */
|
|
190
|
+
async function lastSessionOf({ participantId, workspaceId }) {
|
|
191
|
+
const resolved = workspaceId ?? store.workspaceId;
|
|
192
|
+
const snapshot = resolved === undefined ? null
|
|
193
|
+
: await store.snapshot(resolved, { kinds: ["workspace", "session"] });
|
|
194
|
+
const records = snapshot?.workspace === null
|
|
195
|
+
? await store.ephemeral.list("session")
|
|
196
|
+
: snapshot?.sessions ?? await store.ephemeral.list("session");
|
|
197
|
+
const [last] = records.filter(session => session.participantId === participantId)
|
|
198
|
+
.sort((left, right) => Date.parse(right.heartbeatAt) - Date.parse(left.heartbeatAt)
|
|
199
|
+
|| right.sessionId.localeCompare(left.sessionId));
|
|
200
|
+
return last === undefined ? null : { sessionId: last.sessionId, generation: last.generation,
|
|
201
|
+
state: last.state, endReason: last.extensions?.endReason ?? null };
|
|
202
|
+
}
|
|
203
|
+
|
|
182
204
|
async function listLiveSessions({ participantId, workspaceId, now = clock.now() }) {
|
|
183
205
|
const resolved = workspaceId ?? store.workspaceId;
|
|
184
206
|
const snapshot = resolved === undefined ? null
|
|
@@ -198,6 +220,7 @@ export function createSessionService(ports) {
|
|
|
198
220
|
resumeSession,
|
|
199
221
|
heartbeatSession,
|
|
200
222
|
closeSession,
|
|
223
|
+
lastSessionOf,
|
|
201
224
|
listLiveSessions,
|
|
202
225
|
locateSession: locate,
|
|
203
226
|
ensureMaterialised: options => ensureMaterialised(ports, options),
|
|
@@ -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,7 +108,25 @@ 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
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// A participant with no live session but messages waiting for it stays in
|
|
118
|
+
// the roster, by its latest session, so a sender can see where they went.
|
|
119
|
+
const liveParticipants = new Set(live.map(item => item.session.participantId));
|
|
120
|
+
const latest = new Map();
|
|
121
|
+
for (const { session } of classified) {
|
|
122
|
+
const seen = latest.get(session.participantId);
|
|
123
|
+
if (seen === undefined || Date.parse(session.heartbeatAt) > Date.parse(seen.heartbeatAt)) {
|
|
124
|
+
latest.set(session.participantId, session);
|
|
125
|
+
}
|
|
106
126
|
}
|
|
127
|
+
const awaited = new Set([...latest.values()].filter(session => !liveParticipants.has(session.participantId)
|
|
128
|
+
&& (waiting.get(session.participantId) ?? 0) > 0)
|
|
129
|
+
.map(session => session.sessionId));
|
|
107
130
|
|
|
108
131
|
return {
|
|
109
132
|
workspaceId,
|
|
@@ -115,7 +138,8 @@ export function createStatusService(ports, sessions, deliveryBindings) {
|
|
|
115
138
|
// after a month of work this listed sixty entries for one live session.
|
|
116
139
|
// `acc status --all` is how the worktree-cleanup question is asked.
|
|
117
140
|
participants: classified
|
|
118
|
-
.filter(item => input.all === true || item.presence !== "offline"
|
|
141
|
+
.filter(item => input.all === true || item.presence !== "offline"
|
|
142
|
+
|| awaited.has(item.session.sessionId))
|
|
119
143
|
.map(({ session, presence }) => ({
|
|
120
144
|
sessionId: session.sessionId,
|
|
121
145
|
participantId: session.participantId,
|
|
@@ -132,6 +156,12 @@ export function createStatusService(ports, sessions, deliveryBindings) {
|
|
|
132
156
|
intent: intents.find(intent => intent.sessionId === session.sessionId
|
|
133
157
|
&& isCurrentIntent(intent))?.summary ?? null,
|
|
134
158
|
unretrieved: tally(session.participantId),
|
|
159
|
+
// The client's reason when it ended the session, such as Claude Code's
|
|
160
|
+
// "clear". Kept in the record's extensions, so it is null for 0.7.x.
|
|
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 } : {}),
|
|
135
165
|
})),
|
|
136
166
|
// The owner is named twice on purpose. Every command that reaches a peer
|
|
137
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);
|
|
@@ -5,7 +5,7 @@ import { refreshExpiredBinding } from "./refresh-binding.mjs";
|
|
|
5
5
|
const SAFE_ERRORS = new Set(["ambiguous_recipient_sessions", "delivery_disabled",
|
|
6
6
|
"recipient_busy", "recipient_unavailable", "transport_error", "transport_rejected", "transport_permission_denied",
|
|
7
7
|
"unsupported_client_version"]);
|
|
8
|
-
const NAMED_LIVE_TRANSPORTS = new Set(["claude-
|
|
8
|
+
const NAMED_LIVE_TRANSPORTS = new Set(["claude-inbox", "codex-app-server"]);
|
|
9
9
|
|
|
10
10
|
const adaptersById = adapters => adapters instanceof Map
|
|
11
11
|
? adapters
|
|
@@ -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,14 +45,12 @@ 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
|
|
|
51
|
+
// Read at offer time, not from the binding: consent withdrawn after a session
|
|
52
|
+
// bound must stop the next offer, not the next rebind.
|
|
75
53
|
async function policyFor(adapter, binding) {
|
|
76
|
-
if (adapter?.nativeDelivery?.policySource !== "installation-record") {
|
|
77
|
-
return LIVE_POLICIES.has(binding.livePolicy) ? binding.livePolicy : "off";
|
|
78
|
-
}
|
|
79
54
|
if (typeof readLivePolicy !== "function") return "off";
|
|
80
55
|
try {
|
|
81
56
|
const policy = await readLivePolicy({ adapter, binding });
|
|
@@ -101,7 +76,14 @@ export function createDeliveryRouter({ service, adapters, clock, platform = HOST
|
|
|
101
76
|
// endpoint lease never sends a hook heartbeat: a daemon is reachability,
|
|
102
77
|
// not evidence that this particular session thread is still present.
|
|
103
78
|
const liveSessions = await service.listLiveSessions({ participantId, now });
|
|
104
|
-
if (liveSessions.length === 0)
|
|
79
|
+
if (liveSessions.length === 0) {
|
|
80
|
+
// No open session: the message waits until one starts or resumes. The
|
|
81
|
+
// client's own reason, when it gave one (Claude Code's /clear), is passed on.
|
|
82
|
+
const last = typeof service.lastSessionOf === "function"
|
|
83
|
+
? await service.lastSessionOf({ participantId }).catch(() => null) : null;
|
|
84
|
+
return { ...durable(participantId, "recipient_offline"),
|
|
85
|
+
...(last?.state === "closed" && typeof last.endReason === "string" ? { endReason: last.endReason } : {}) };
|
|
86
|
+
}
|
|
105
87
|
if (liveSessions.length > 1) {
|
|
106
88
|
return durable(participantId, "ambiguous_recipient_sessions");
|
|
107
89
|
}
|
|
@@ -110,7 +92,9 @@ export function createDeliveryRouter({ service, adapters, clock, platform = HOST
|
|
|
110
92
|
participantId, now, includeExpired: true }))
|
|
111
93
|
.filter(binding => binding.sessionId === target.sessionId
|
|
112
94
|
&& binding.generation === target.generation);
|
|
113
|
-
|
|
95
|
+
// Online, with nothing to push through: a CLI participant, or a client
|
|
96
|
+
// that bound no live transport. It reads its inbox, so it is not gone.
|
|
97
|
+
if (bindings.length === 0) return durable(participantId, "no_live_transport");
|
|
114
98
|
const evaluated = await Promise.all(bindings.map(async binding => {
|
|
115
99
|
const adapter = registry.get(binding.adapterId);
|
|
116
100
|
return { binding, adapter, policy: await policyFor(adapter, binding) };
|
|
@@ -118,7 +102,7 @@ export function createDeliveryRouter({ service, adapters, clock, platform = HOST
|
|
|
118
102
|
const permitted = evaluated.filter(({ policy }) => permits(policy, message.kind));
|
|
119
103
|
if (permitted.length === 0) return durable(participantId, "delivery_disabled");
|
|
120
104
|
const reachable = permitted.filter(({ binding }) => binding.availableModes.includes("livePush"));
|
|
121
|
-
if (reachable.length === 0) return durable(participantId, "
|
|
105
|
+
if (reachable.length === 0) return durable(participantId, "no_live_transport");
|
|
122
106
|
const capable = reachable.filter(({ binding, adapter }) => liveCapable(adapter, binding));
|
|
123
107
|
if (capable.length === 0) {
|
|
124
108
|
return durable(participantId, "unsupported_client_version");
|
|
@@ -131,7 +115,7 @@ export function createDeliveryRouter({ service, adapters, clock, platform = HOST
|
|
|
131
115
|
const { adapter } = capable[0];
|
|
132
116
|
if (Date.parse(binding.leaseUntil) <= Date.parse(now)) {
|
|
133
117
|
const refreshed = await refreshExpiredBinding({ service, adapter, binding,
|
|
134
|
-
runtimeDir: service.store?.root,
|
|
118
|
+
runtimeDir: service.store?.root, clock });
|
|
135
119
|
if (!refreshed) return durable(participantId, "recipient_unavailable");
|
|
136
120
|
const current = (await service.listDeliveryBindings({
|
|
137
121
|
participantId, now: clock.now() })).filter(item => item.sessionId === binding.sessionId
|
|
@@ -197,23 +181,24 @@ export function createDeliveryRouter({ service, adapters, clock, platform = HOST
|
|
|
197
181
|
// Compatibility was already decided at bind time; a serving version that
|
|
198
182
|
// still satisfies the adapter's captured contract keeps offering, even
|
|
199
183
|
// when it differs from the value recorded when the binding was created.
|
|
200
|
-
// Only a version below the captured minimum or on the denylist refuses
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
// does not make this unreachable - a complete, valid contract that names
|
|
206
|
-
// only darwin-arm64 says nothing about linux-x64, and that is every
|
|
207
|
-
// shipped adapter on Linux.
|
|
208
|
-
const versionRule = evaluateVersionContract(adapter, { clientVersion: response.clientVersion, platform });
|
|
209
|
-
const admitted = versionRule.reasonCode === null
|
|
210
|
-
|| (versionRule.reasonCode === UNCAPTURED_PLATFORM
|
|
211
|
-
&& response.clientVersion === binding.clientVersion);
|
|
212
|
-
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) {
|
|
213
189
|
await recordFailure(binding, message, participantId, transport,
|
|
214
190
|
"unsupported_client_version");
|
|
215
191
|
return durable(participantId, "unsupported_client_version");
|
|
216
192
|
}
|
|
193
|
+
// A wake put a notice in front of the client, not the body. Recording it
|
|
194
|
+
// as offered would make the next-turn projection treat the body as already
|
|
195
|
+
// shown and leave it out of the very turn the wake started. The receipt
|
|
196
|
+
// stays queued; the hook records the offer once its stdout carried the body.
|
|
197
|
+
if (adapter.nativeDelivery?.offerKind === "wake") {
|
|
198
|
+
// The client took the wake but holds it for its user's approval.
|
|
199
|
+
return { recipientParticipantId: participantId, outcome: "woken", transport,
|
|
200
|
+
...(response.pendingApproval === true ? { pendingApproval: true } : {}) };
|
|
201
|
+
}
|
|
217
202
|
try {
|
|
218
203
|
await service.recordOfferSucceeded({ messageId: message.messageId,
|
|
219
204
|
recipientParticipantId: participantId, targetSessionId: binding.sessionId,
|
|
@@ -4,6 +4,35 @@ import path from "node:path";
|
|
|
4
4
|
// enough that a table which disagrees with itself cannot spin.
|
|
5
5
|
const MAX_HOPS = 16;
|
|
6
6
|
|
|
7
|
+
// A client shipped as a node script runs as `node`, so its comm names the
|
|
8
|
+
// interpreter. Gemini CLI is one, measured on 0.60.0. For these, the script the
|
|
9
|
+
// interpreter was given names the client.
|
|
10
|
+
const SCRIPT_HOSTS = new Set(["node"]);
|
|
11
|
+
const SCRIPT_EXTENSION = /\.[mc]?js$/;
|
|
12
|
+
|
|
13
|
+
/** What the script a script host runs could be called, without extensions. */
|
|
14
|
+
function scriptsOf(entry) {
|
|
15
|
+
if (!SCRIPT_HOSTS.has(path.basename(entry.comm)) || typeof entry.args !== "string") return [];
|
|
16
|
+
// `args` starts with the interpreter as it was invoked, which is the comm
|
|
17
|
+
// itself on macOS; past it, the first word that is no option is the script -
|
|
18
|
+
// unless an option written without `=` came right before it, since that word
|
|
19
|
+
// may be the option's value (`--require ./preload.cjs`). Which options take
|
|
20
|
+
// one depends on the client's own node, so such a word is kept as a candidate
|
|
21
|
+
// and the search goes on; it ends at the first word no bare option precedes.
|
|
22
|
+
const rest = entry.args.startsWith(`${entry.comm} `) ? entry.args.slice(entry.comm.length)
|
|
23
|
+
: entry.args.replace(/^\S+/, "");
|
|
24
|
+
const candidates = [];
|
|
25
|
+
let afterBareOption = false;
|
|
26
|
+
for (const word of rest.trim().split(/\s+/)) {
|
|
27
|
+
if (word === "") continue;
|
|
28
|
+
if (word.startsWith("-")) { afterBareOption = !word.includes("="); continue; }
|
|
29
|
+
candidates.push(path.basename(word).replace(SCRIPT_EXTENSION, ""));
|
|
30
|
+
if (!afterBareOption) break;
|
|
31
|
+
afterBareOption = false;
|
|
32
|
+
}
|
|
33
|
+
return candidates;
|
|
34
|
+
}
|
|
35
|
+
|
|
7
36
|
/**
|
|
8
37
|
* The pid of the client this hook is running for, or null when nobody knows.
|
|
9
38
|
*
|
|
@@ -24,7 +53,7 @@ export function resolveClientPid({ table, from, command, maxHops = MAX_HOPS }) {
|
|
|
24
53
|
seen.add(current);
|
|
25
54
|
// `ps` reports some entries bare (`claude`) and some with a path
|
|
26
55
|
// (`/bin/zsh`), so the comparison has to be on the basename.
|
|
27
|
-
if (path.basename(entry.comm) === command) return current;
|
|
56
|
+
if (path.basename(entry.comm) === command || scriptsOf(entry).includes(command)) return current;
|
|
28
57
|
if (entry.ppid === current || entry.ppid <= 1) return null;
|
|
29
58
|
current = entry.ppid;
|
|
30
59
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { storeNativeAttempt } from "@agents-can-communicate/adapter-sdk";
|
|
2
2
|
import { readInstalledLivePolicyState } from "@agents-can-communicate/installer";
|
|
3
3
|
|
|
4
|
-
import { establishNativeBinding
|
|
4
|
+
import { establishNativeBinding } from "./native-binding.mjs";
|
|
5
5
|
|
|
6
6
|
// A cold worker plus the write has to finish inside this window. 250 ms was
|
|
7
7
|
// not enough on a busy CI runner, so the attempt file never appeared and
|
|
@@ -27,13 +27,11 @@ export async function bindNative({ adapter, event, hookBinding, clientVersion, p
|
|
|
27
27
|
if (Date.now() >= deadline) throw new Error("hook deadline expired");
|
|
28
28
|
};
|
|
29
29
|
assertBudget();
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
const { policy, policyStatus } =
|
|
34
|
-
|
|
35
|
-
: { policy: livePolicyFrom(context.env), policyStatus: raw === undefined ? "missing"
|
|
36
|
-
: !LIVE_POLICIES.includes(raw) ? "invalid" : raw === "off" ? "off" : "enabled" };
|
|
30
|
+
// The install record is the only consent a hook reads; see
|
|
31
|
+
// validateNativeDeliveryContract for the environment source this replaced.
|
|
32
|
+
const policySource = "installation-record";
|
|
33
|
+
const { policy, policyStatus } = await readInstalledLivePolicyState({ dataHome: context.dataHome,
|
|
34
|
+
adapterId: adapter.id });
|
|
37
35
|
assertBudget();
|
|
38
36
|
const result = await establishNativeBinding({ adapter, event, hookBinding, clientVersion, platform,
|
|
39
37
|
livePolicy: policy, service: context.service, runtimeDir: paths.root,
|
|
@@ -16,19 +16,24 @@ const DEFAULT_CADENCE_MS = 60_000;
|
|
|
16
16
|
|
|
17
17
|
// Reasons the static rule refused: the client will not change within the
|
|
18
18
|
// session, so the state is "unsupported" rather than a retryable "degraded".
|
|
19
|
-
const STATIC_REASONS = new Set(["native_delivery_unsupported", "
|
|
20
|
-
"
|
|
21
|
-
"known_bad_version"]);
|
|
22
|
-
|
|
23
|
-
// Only the value a successful owned shell bootstrap exported counts; anything
|
|
24
|
-
// else - missing, blank, differently cased, a number - is off.
|
|
25
|
-
export function livePolicyFrom(env) {
|
|
26
|
-
const value = env?.ACC_NATIVE_DELIVERY_POLICY;
|
|
27
|
-
return LIVE_POLICIES.includes(value) ? value : "off";
|
|
28
|
-
}
|
|
19
|
+
const STATIC_REASONS = new Set(["native_delivery_unsupported", "version_unavailable",
|
|
20
|
+
"below_minimum_version", "known_bad_version"]);
|
|
29
21
|
|
|
30
22
|
const isPid = value => Number.isInteger(value) && value > 0;
|
|
31
23
|
|
|
24
|
+
// An adapter may write its private endpoint before the static rule below judges
|
|
25
|
+
// the handshake, or after this hook stopped waiting. Nothing publishes such an
|
|
26
|
+
// endpoint, so nothing would ever retire it, and a refused client binds again
|
|
27
|
+
// on every turn. Fail-open and never awaited past the adapter's own answer.
|
|
28
|
+
function discardEndpoint(adapter, handshake, runtimeDir) {
|
|
29
|
+
const opaqueEndpointRef = handshake?.opaqueEndpointRef;
|
|
30
|
+
if (typeof opaqueEndpointRef !== "string" || opaqueEndpointRef === ""
|
|
31
|
+
|| typeof adapter?.retireNativeSession !== "function") return Promise.resolve();
|
|
32
|
+
return Promise.resolve().then(() => adapter.retireNativeSession({
|
|
33
|
+
binding: { opaqueEndpointRef, clientVersion: handshake.clientVersion ?? null }, runtimeDir }))
|
|
34
|
+
.catch(() => {});
|
|
35
|
+
}
|
|
36
|
+
|
|
32
37
|
export async function establishNativeBinding({ adapter, event, hookBinding, clientVersion,
|
|
33
38
|
platform, livePolicy, service, runtimeDir, clock, env,
|
|
34
39
|
timeoutMs = DEFAULT_TIMEOUT_MS, heartbeatCadenceMs = DEFAULT_CADENCE_MS }) {
|
|
@@ -57,16 +62,23 @@ export async function establishNativeBinding({ adapter, event, hookBinding, clie
|
|
|
57
62
|
let timer = null;
|
|
58
63
|
try {
|
|
59
64
|
const budget = Math.max(1, Math.floor(timeoutMs));
|
|
65
|
+
const pending = Promise.resolve().then(() => adapter.bindNativeSession({ event, clientPid,
|
|
66
|
+
clientVersion, runtimeDir, timeoutMs: budget, env }));
|
|
67
|
+
// An answer that lands after the budget has nobody left to use it.
|
|
68
|
+
let timedOut = false;
|
|
69
|
+
pending.then(late => { if (timedOut) discardEndpoint(adapter, late, runtimeDir); }, () => {});
|
|
60
70
|
const handshake = await Promise.race([
|
|
61
|
-
|
|
62
|
-
timeoutMs: budget, env }),
|
|
71
|
+
pending,
|
|
63
72
|
new Promise((_resolve, reject) => {
|
|
64
|
-
timer = setTimeout(() =>
|
|
65
|
-
|
|
73
|
+
timer = setTimeout(() => {
|
|
74
|
+
timedOut = true;
|
|
75
|
+
reject(Object.assign(new Error("native handshake timed out"), { code: "ETIMEDOUT" }));
|
|
76
|
+
}, budget);
|
|
66
77
|
}),
|
|
67
78
|
]);
|
|
68
|
-
const verdict = validateNativeHandshake(adapter, { clientVersion,
|
|
79
|
+
const verdict = validateNativeHandshake(adapter, { clientVersion, handshake });
|
|
69
80
|
if (!verdict.ok) {
|
|
81
|
+
await discardEndpoint(adapter, handshake, runtimeDir);
|
|
70
82
|
return outcome(STATIC_REASONS.has(verdict.reasonCode) ? "unsupported" : "degraded",
|
|
71
83
|
verdict.reasonCode);
|
|
72
84
|
}
|