agents-can-communicate 0.8.0 → 0.8.2
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 +50 -17
- package/docs/ARCHITECTURE.md +9 -6
- package/docs/CAPABILITIES.md +24 -14
- package/docs/CLI.md +22 -3
- package/docs/CONFIGURATION.md +73 -8
- package/docs/GETTING_STARTED.md +6 -3
- package/docs/HOW_IT_WORKS.md +3 -1
- package/docs/PROTOCOL.md +2 -2
- package/docs/RELEASING.md +6 -4
- package/docs/SECURITY_MODEL.md +12 -1
- package/docs/TROUBLESHOOTING.md +34 -7
- package/docs/UPGRADING.md +29 -1
- 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/plugin/skills/acc/SKILL.md +7 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/adapter.mjs +5 -4
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/allow-rule.mjs +204 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/install.mjs +45 -10
- 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 +14 -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 +5 -3
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-observed-directories.mjs +62 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-socket-directories.mjs +41 -0
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +6 -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/install.mjs +5 -4
- package/node_modules/@agents-can-communicate/adapter-codex/src/live-permissions.mjs +69 -17
- 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/capabilities.mjs +7 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/certification.mjs +2 -2
- package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +16 -2
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +4 -3
- 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/doctor-command.mjs +9 -4
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +11 -4
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +49 -12
- 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/native-session-diagnostics.mjs +20 -10
- 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 +26 -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 +12 -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 +13 -7
- package/node_modules/@agents-can-communicate/installer/src/index.mjs +1 -0
- package/node_modules/@agents-can-communicate/installer/src/receiver-sockets.mjs +19 -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/storage-filesystem/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +30 -4
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +56 -25
- package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +6 -2
- package/package.json +1 -1
|
@@ -16,7 +16,7 @@ const usage = message => new AccError(EXIT.USAGE, message);
|
|
|
16
16
|
import { commandHelpText, describeCommand, describeCommands, helpText } from "./help.mjs";
|
|
17
17
|
import { runUpdateCommand } from "./update-command.mjs";
|
|
18
18
|
import { runConfigCommand } from "./config-command.mjs";
|
|
19
|
-
import { runInstallCommand } from "./install-command.mjs";
|
|
19
|
+
import { ALL_ADAPTERS, runInstallCommand } from "./install-command.mjs";
|
|
20
20
|
import { runDoctor } from "./doctor-command.mjs";
|
|
21
21
|
import { runManagementDoctor } from "./managed-runtime/diagnostics.mjs";
|
|
22
22
|
import { createGitProbe } from "./git-probe.mjs";
|
|
@@ -179,12 +179,49 @@ export async function recordAndOffer({ record, router, selectMessage = value =>
|
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
/**
|
|
183
|
-
|
|
182
|
+
/**
|
|
183
|
+
* What a message sent to each stale session would meet.
|
|
184
|
+
*
|
|
185
|
+
* Presence reads the client process, so a dead one is offline and a stale
|
|
186
|
+
* session with a live process is idle. Idle is then told apart by delivery: a
|
|
187
|
+
* live binding wakes it, a client with next-turn delivery shows the message on
|
|
188
|
+
* its next turn, and anything else reads it through `acc inbox`. A session with
|
|
189
|
+
* no known process cannot be told from one that left, so it is not answering.
|
|
190
|
+
*
|
|
191
|
+
* A live binding wakes on the router's own rule: a current lease, or a lapsed
|
|
192
|
+
* one the adapter can re-verify. Without that the next send is durable.
|
|
193
|
+
*/
|
|
194
|
+
export function presenceBreakdown(participants = [], adapters = ALL_ADAPTERS()) {
|
|
195
|
+
const nextTurn = new Set(adapters.filter(adapter => adapter.capabilities?.delivery?.nextTurn === true)
|
|
196
|
+
.map(adapter => adapter.id));
|
|
197
|
+
const refreshes = new Set(adapters.filter(adapter => typeof adapter.refreshNativeSession === "function")
|
|
198
|
+
.map(adapter => adapter.id));
|
|
199
|
+
const wakes = participant => participant.liveDelivery === true
|
|
200
|
+
&& (participant.liveLeaseCurrent === true || refreshes.has(participant.harness));
|
|
201
|
+
const idle = { wake: 0, nextTurn: 0, inbox: 0, unknown: 0 };
|
|
202
|
+
for (const participant of participants.filter(item => item.presence === "stale")) {
|
|
203
|
+
const kind = participant.processTracked !== true ? "unknown"
|
|
204
|
+
: wakes(participant) ? "wake"
|
|
205
|
+
: nextTurn.has(participant.harness) ? "nextTurn" : "inbox";
|
|
206
|
+
idle[kind] += 1;
|
|
207
|
+
}
|
|
208
|
+
return idle;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* How many are here, and what a message would meet with those not active.
|
|
213
|
+
* Without a breakdown every stale session counts as not answering, the answer
|
|
214
|
+
* before presence could tell an idle session from one whose client had left.
|
|
215
|
+
*/
|
|
216
|
+
export function describePresence({ live = 0, stale = 0 } = {},
|
|
217
|
+
idle = { wake: 0, nextTurn: 0, inbox: 0, unknown: stale }) {
|
|
184
218
|
if (live === 0) return "0 live";
|
|
185
219
|
if (stale === 0) return `${live} live`;
|
|
186
|
-
if (
|
|
187
|
-
|
|
220
|
+
if (idle.unknown === live) return `${live} present, none answering`;
|
|
221
|
+
const parts = [[idle.wake, "idle, wake on send"], [idle.nextTurn, "idle until next turn"],
|
|
222
|
+
[idle.inbox, "idle, inbox only"], [idle.unknown, "not answering"]]
|
|
223
|
+
.filter(([count]) => count > 0).map(([count, text]) => `${count} ${text}`);
|
|
224
|
+
return `${live} live (${parts.join("; ")})`;
|
|
188
225
|
}
|
|
189
226
|
|
|
190
227
|
export function describeStatus(status) {
|
|
@@ -193,19 +230,19 @@ export function describeStatus(status) {
|
|
|
193
230
|
// stops being answered but does not disappear. Printing the number alone
|
|
194
231
|
// said "1 live" about a workspace where the last agent had left minutes
|
|
195
232
|
// before, which is the one thing a person reads this line to find out.
|
|
196
|
-
const line = `${describePresence(status.counts
|
|
233
|
+
const line = `${describePresence(status.counts, presenceBreakdown(status.participants))}; `
|
|
234
|
+
+ `${status.counts.claims} claim(s); `
|
|
197
235
|
+ `protection ${status.protection}`;
|
|
198
236
|
// A transport accepting a message is not a model reading it. Said only when
|
|
199
237
|
// it is true of something, so the everyday line stays the everyday line.
|
|
200
238
|
const offered = status.counts.unretrieved?.offered ?? 0;
|
|
201
239
|
const withOffers = offered === 0 ? line : `${line}; ${offered} offered, not retrieved`;
|
|
202
240
|
// 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
|
-
});
|
|
241
|
+
// for it; each is named here, with the client's reason when it gave one. The
|
|
242
|
+
// count is what its next turn would still show, never an offer already made.
|
|
243
|
+
const waiting = (status.participants ?? [])
|
|
244
|
+
.filter(item => item.presence === "offline" && (item.waiting ?? 0) > 0)
|
|
245
|
+
.map(item => `${item.participantId} (${item.endReason === "clear" ? "cleared, " : ""}${item.waiting})`);
|
|
209
246
|
return waiting.length === 0 ? withOffers
|
|
210
247
|
: `${withOffers}; waiting for a closed session: ${waiting.join(", ")}`;
|
|
211
248
|
}
|
|
@@ -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
|
}
|
|
@@ -60,14 +60,19 @@ export async function updateNativeSessions(adapters, { service, status, root, no
|
|
|
60
60
|
&& b.availableModes.includes("livePush"));
|
|
61
61
|
const off = native.policySource === "installation-record" && !native.configured;
|
|
62
62
|
const leased = binding !== undefined && Date.parse(binding.leaseUntil) > Date.parse(now);
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
63
|
+
// A lapsed lease is asked about too. Nothing renews it while a session
|
|
64
|
+
// sits idle, and the router does not refuse it: it re-verifies the
|
|
65
|
+
// receiver and refreshes the lease before it offers. Stopping at the
|
|
66
|
+
// lease reported sessions that answered a live question in seconds as
|
|
67
|
+
// degraded, with advice to start a new one.
|
|
68
|
+
const delivery = off || binding === undefined ? null
|
|
67
69
|
: await verifyDelivery(byAdapterId.get(entry.adapterId), { binding, runtimeDir: root });
|
|
70
|
+
// Without a re-verification the router cannot refresh a lapsed lease
|
|
71
|
+
// either, so only a live lease keeps the old answer.
|
|
68
72
|
const runtime = off ? "inactive"
|
|
69
73
|
: binding === undefined ? "unbound"
|
|
70
|
-
:
|
|
74
|
+
: delivery === null ? (leased ? "active" : "degraded")
|
|
75
|
+
: !delivery.deliverable ? "degraded" : leased ? "active" : "idle";
|
|
71
76
|
native.sessions.push({ sessionId: peer.sessionId, participantId: peer.participantId,
|
|
72
77
|
presence: peer.presence, runtime, clientVersion: binding?.clientVersion ?? null, delivery,
|
|
73
78
|
lastAttempt: owner === null ? null : await loadNativeAttempt({ runtimeDir: root, ...owner }) });
|
|
@@ -76,10 +81,13 @@ export async function updateNativeSessions(adapters, { service, status, root, no
|
|
|
76
81
|
// one claiming health while nothing could be delivered. If this adapter
|
|
77
82
|
// has bound sessions in this workspace and not one of them can take a
|
|
78
83
|
// push, this is not an active transport.
|
|
79
|
-
|
|
80
|
-
|
|
84
|
+
const reachable = native.sessions.some(session => ["active", "idle"].includes(session.runtime));
|
|
85
|
+
if (native.runtime === "active" && native.sessions.length > 0 && !reachable) {
|
|
81
86
|
native.runtime = "degraded";
|
|
82
87
|
}
|
|
88
|
+
// The other direction, which the binding pass cannot see: it reads a
|
|
89
|
+
// lapsed lease as unreachable, and a receiver that answered says otherwise.
|
|
90
|
+
if (native.runtime === "degraded" && reachable) native.runtime = "active";
|
|
83
91
|
}
|
|
84
92
|
}
|
|
85
93
|
|
|
@@ -131,10 +139,12 @@ function describeDelivery(session) {
|
|
|
131
139
|
: "receiver verified";
|
|
132
140
|
}
|
|
133
141
|
|
|
142
|
+
const RUNTIME_TEXT = { active: "local transport active", idle: "idle, lease lapsed" };
|
|
143
|
+
|
|
134
144
|
export function nativeSessionLines(adapters) {
|
|
135
145
|
return adapters.flatMap(entry => (entry.nativeDelivery.sessions ?? []).map(session =>
|
|
136
146
|
` ${entry.displayName} session ${session.participantId} (${session.sessionId}): `
|
|
137
|
-
+ `${session.runtime
|
|
138
|
-
+ [describeDelivery(session),
|
|
139
|
-
.filter(part => part !== null).join("; ")));
|
|
147
|
+
+ `${RUNTIME_TEXT[session.runtime] ?? session.runtime}; `
|
|
148
|
+
+ [describeDelivery(session), session.runtime === "idle" ? "the next send refreshes it" : null,
|
|
149
|
+
describeAttempt(session.lastAttempt)].filter(part => part !== null).join("; ")));
|
|
140
150
|
}
|
|
@@ -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.
|
|
@@ -88,6 +89,12 @@ export function createStatusService(ports, sessions, deliveryBindings) {
|
|
|
88
89
|
const claims = snapshot.claims
|
|
89
90
|
.filter(claim => Date.parse(claim.expiresAt) > Date.parse(now));
|
|
90
91
|
const currentBindings = await deliveryBindings.currentBindings(now);
|
|
92
|
+
// A live binding and its lease are reported apart. The router refreshes a
|
|
93
|
+
// lapsed lease only through the adapter's own re-verification, and which
|
|
94
|
+
// adapters have one is not a fact core may know, so the reader decides.
|
|
95
|
+
const liveBound = new Map(currentBindings
|
|
96
|
+
.filter(({ binding }) => binding.availableModes.includes("livePush"))
|
|
97
|
+
.map(({ binding, reachable }) => [binding.sessionId, reachable]));
|
|
91
98
|
// What each recipient has not fetched yet, and how far it got. Offered is
|
|
92
99
|
// not read: a transport took the bytes. Queued sits beside it so "0
|
|
93
100
|
// offered" cannot be mistaken for "nothing waits". Replaced decisions
|
|
@@ -96,6 +103,10 @@ export function createStatusService(ports, sessions, deliveryBindings) {
|
|
|
96
103
|
.filter(isCurrentDecision).map(message => message.messageId));
|
|
97
104
|
const unretrieved = new Map();
|
|
98
105
|
const tally = key => unretrieved.get(key) ?? { queued: 0, offered: 0 };
|
|
106
|
+
// Narrower than unretrieved: what the recipient's next turn would still
|
|
107
|
+
// show. An offer the next turn already made is unretrieved, but nothing
|
|
108
|
+
// will ever reach the recipient for it again.
|
|
109
|
+
const waiting = new Map();
|
|
99
110
|
for (const receipt of snapshot.receipts) {
|
|
100
111
|
if (!current.has(receipt.messageId)
|
|
101
112
|
|| !["queued", "offered"].includes(receipt.state)) continue;
|
|
@@ -103,6 +114,10 @@ export function createStatusService(ports, sessions, deliveryBindings) {
|
|
|
103
114
|
const counts = tally(key);
|
|
104
115
|
unretrieved.set(key, { ...counts, [receipt.state]: counts[receipt.state] + 1 });
|
|
105
116
|
}
|
|
117
|
+
if (awaitsNextTurn(receipt)) {
|
|
118
|
+
waiting.set(receipt.recipientParticipantId,
|
|
119
|
+
(waiting.get(receipt.recipientParticipantId) ?? 0) + 1);
|
|
120
|
+
}
|
|
106
121
|
}
|
|
107
122
|
|
|
108
123
|
// A participant with no live session but messages waiting for it stays in
|
|
@@ -116,7 +131,7 @@ export function createStatusService(ports, sessions, deliveryBindings) {
|
|
|
116
131
|
}
|
|
117
132
|
}
|
|
118
133
|
const awaited = new Set([...latest.values()].filter(session => !liveParticipants.has(session.participantId)
|
|
119
|
-
&& (
|
|
134
|
+
&& (waiting.get(session.participantId) ?? 0) > 0)
|
|
120
135
|
.map(session => session.sessionId));
|
|
121
136
|
|
|
122
137
|
return {
|
|
@@ -141,6 +156,12 @@ export function createStatusService(ports, sessions, deliveryBindings) {
|
|
|
141
156
|
enforcement: session.enforcement ?? "advisory",
|
|
142
157
|
lifecycle: session.lifecycle ?? "manual",
|
|
143
158
|
presence,
|
|
159
|
+
// What a stale presence means depends on these. With the process known,
|
|
160
|
+
// stale is idle, since a dead process is offline; with a live binding,
|
|
161
|
+
// a push can wake it. Only a session nobody can observe is not answering.
|
|
162
|
+
processTracked: (session.pid ?? null) !== null,
|
|
163
|
+
liveDelivery: liveBound.has(session.sessionId),
|
|
164
|
+
liveLeaseCurrent: liveBound.get(session.sessionId) === true,
|
|
144
165
|
// A finished intent reads as no intent at all. `--clear` exists so a
|
|
145
166
|
// peer stops seeing work that has stopped, and reporting the summary of
|
|
146
167
|
// a done record made the command and this line contradict each other.
|
|
@@ -149,7 +170,10 @@ export function createStatusService(ports, sessions, deliveryBindings) {
|
|
|
149
170
|
unretrieved: tally(session.participantId),
|
|
150
171
|
// The client's reason when it ended the session, such as Claude Code's
|
|
151
172
|
// "clear". Kept in the record's extensions, so it is null for 0.7.x.
|
|
152
|
-
|
|
173
|
+
// And how many messages its next turn would still show, the count that
|
|
174
|
+
// keeps it listed.
|
|
175
|
+
...(presence === "offline" ? { endReason: session.extensions?.endReason ?? null,
|
|
176
|
+
waiting: waiting.get(session.participantId) ?? 0 } : {}),
|
|
153
177
|
})),
|
|
154
178
|
// The owner is named twice on purpose. Every command that reaches a peer
|
|
155
179
|
// 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");
|
|
@@ -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
|
}
|
|
@@ -16,9 +16,8 @@ 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"]);
|
|
19
|
+
const STATIC_REASONS = new Set(["native_delivery_unsupported", "version_unavailable",
|
|
20
|
+
"below_minimum_version", "known_bad_version"]);
|
|
22
21
|
|
|
23
22
|
const isPid = value => Number.isInteger(value) && value > 0;
|
|
24
23
|
|
|
@@ -77,7 +76,7 @@ export async function establishNativeBinding({ adapter, event, hookBinding, clie
|
|
|
77
76
|
}, budget);
|
|
78
77
|
}),
|
|
79
78
|
]);
|
|
80
|
-
const verdict = validateNativeHandshake(adapter, { clientVersion,
|
|
79
|
+
const verdict = validateNativeHandshake(adapter, { clientVersion, handshake });
|
|
81
80
|
if (!verdict.ok) {
|
|
82
81
|
await discardEndpoint(adapter, handshake, runtimeDir);
|
|
83
82
|
return outcome(STATIC_REASONS.has(verdict.reasonCode) ? "unsupported" : "degraded",
|
|
@@ -8,27 +8,43 @@ const run = promisify(execFile);
|
|
|
8
8
|
const DEFAULT_TIMEOUT_MS = 1_000;
|
|
9
9
|
|
|
10
10
|
const LINE = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/;
|
|
11
|
+
// A pid, then the whole rest of the line: a command line has spaces anywhere.
|
|
12
|
+
const ARGS_LINE = /^\s*(\d+)\s+(.+?)\s*$/;
|
|
13
|
+
// Command lines are long; measured at 231 KB for 1,137 processes on a desktop
|
|
14
|
+
// Mac, a quarter of execFile's default buffer.
|
|
15
|
+
const ARGS_BUFFER = 16 * 1024 * 1024;
|
|
11
16
|
|
|
12
17
|
/**
|
|
13
|
-
* Every process on this machine, as pid -> parent and
|
|
18
|
+
* Every process on this machine, as pid -> parent, executable and, when it
|
|
19
|
+
* could be read, command line.
|
|
14
20
|
*
|
|
15
21
|
* Returns an empty map rather than throwing when the platform has no `ps`
|
|
16
22
|
* (Windows) or the call fails. An empty table resolves no client, which is the
|
|
17
|
-
* "nobody knows" answer the caller already handles.
|
|
23
|
+
* "nobody knows" answer the caller already handles. The command lines are read
|
|
24
|
+
* at the same time and within the same timeout; they only name a client that
|
|
25
|
+
* is a script, so when that read fails every entry keeps what it had before.
|
|
18
26
|
*/
|
|
19
27
|
export async function readProcessTable({ run: exec = run,
|
|
20
28
|
timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
return new Map();
|
|
29
|
+
const options = { timeout: timeoutMs, killSignal: "SIGKILL" };
|
|
30
|
+
const [tree, commandLines] = await Promise.all([
|
|
31
|
+
exec("ps", ["-o", "pid=,ppid=,comm=", "-A"], options).then(({ stdout }) => stdout, () => null),
|
|
32
|
+
exec("ps", ["-A", "-o", "pid=,args="], { ...options, maxBuffer: ARGS_BUFFER })
|
|
33
|
+
.then(({ stdout }) => stdout, () => ""),
|
|
34
|
+
]);
|
|
35
|
+
if (typeof tree !== "string") return new Map();
|
|
36
|
+
const args = new Map();
|
|
37
|
+
for (const line of String(commandLines).split("\n")) {
|
|
38
|
+
const match = ARGS_LINE.exec(line);
|
|
39
|
+
if (match !== null) args.set(Number(match[1]), match[2]);
|
|
33
40
|
}
|
|
41
|
+
const table = new Map();
|
|
42
|
+
for (const line of tree.split("\n")) {
|
|
43
|
+
const match = LINE.exec(line);
|
|
44
|
+
if (match === null) continue;
|
|
45
|
+
const pid = Number(match[1]);
|
|
46
|
+
table.set(pid, { ppid: Number(match[2]), comm: match[3],
|
|
47
|
+
...(args.has(pid) ? { args: args.get(pid) } : {}) });
|
|
48
|
+
}
|
|
49
|
+
return table;
|
|
34
50
|
}
|
|
@@ -400,8 +400,10 @@ const HANDLERS = {
|
|
|
400
400
|
const facts = await runtimeFacts(import.meta.url);
|
|
401
401
|
await storeSessionBinding({ runtimeDir: paths.root, deadlineAt: deadline, harnessSessionId: event.sessionId,
|
|
402
402
|
accSessionId: binding.accSessionId, generation: binding.generation, ...facts });
|
|
403
|
+
// The pid this binding already knew keeps the refreshed pin reapable if
|
|
404
|
+
// the lookup below cannot find the client.
|
|
403
405
|
await writePin({ root: pinRoot, harnessSessionId: event.sessionId, runtimeRoot: facts.runtimeRoot,
|
|
404
|
-
version: facts.version, storeVersion: facts.storeVersion });
|
|
406
|
+
version: facts.version, storeVersion: facts.storeVersion, clientPid: binding.clientPid });
|
|
405
407
|
}
|
|
406
408
|
const clientVersion = await probeClientVersion(adapter,
|
|
407
409
|
{ timeoutMs: Math.max(1, Math.min(1_000, deadline - Date.now())) });
|
|
@@ -600,6 +602,15 @@ const HANDLERS = {
|
|
|
600
602
|
async beforeTool({ binding, context, event, adapter }) {
|
|
601
603
|
if (binding === null) return { decision: "allow" };
|
|
602
604
|
if (event.targets.length === 0) {
|
|
605
|
+
// A shell command is work too. The refresh below sat behind the write
|
|
606
|
+
// guard, so a turn spent in the shell went stale three minutes in, measured
|
|
607
|
+
// on 0.8.1. With no guard read to borrow from, the session's own record is
|
|
608
|
+
// the narrow read that says whether a heartbeat is due.
|
|
609
|
+
const own = await context.service.locateSession(binding.accSessionId).catch(() => null);
|
|
610
|
+
if (own !== null && needsRefresh(own.record.heartbeatAt, Date.now())) {
|
|
611
|
+
await context.service.heartbeatSession({ sessionId: binding.accSessionId,
|
|
612
|
+
generation: binding.generation }).catch(() => null);
|
|
613
|
+
}
|
|
603
614
|
// The runner cannot tell what a shell command touches. Saying so is the
|
|
604
615
|
// honest answer; guessing would block work at random and miss real writes.
|
|
605
616
|
return { decision: "allow", unguarded: true };
|