agents-can-communicate 0.1.18 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -70
- package/SECURITY.md +31 -0
- package/bin/acc-hook.mjs +94 -12
- package/bin/acc-mcp.mjs +6 -2
- package/bin/acc.mjs +6 -1
- package/docs/ADAPTER_AUTHORING.md +172 -0
- package/docs/ARCHITECTURE.md +131 -0
- package/docs/CAPABILITIES.md +102 -214
- package/docs/CLI.md +157 -0
- package/docs/CONCEPTS.md +134 -0
- package/docs/CONFIGURATION.md +143 -0
- package/docs/DESIGN_DECISIONS.md +89 -0
- package/docs/GETTING_STARTED.md +145 -0
- package/docs/GLOSSARY.md +26 -0
- package/docs/MCP.md +94 -0
- package/docs/PROTOCOL.md +200 -0
- package/docs/RELEASING.md +109 -0
- package/docs/SECURITY_MODEL.md +131 -0
- package/docs/TROUBLESHOOTING.md +102 -0
- package/docs/WHY_ACC.md +61 -0
- package/docs/index.md +42 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/certification.json +78 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/PreToolUse-Edit.json +19 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/PreToolUse.json +17 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/SessionEnd.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/SessionStart.json +7 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/UserPromptSubmit.json +9 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/certification-provenance.json +77 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.252.json +19 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +9 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +20 -22
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +12 -4
- package/node_modules/@agents-can-communicate/adapter-codex/certification.json +117 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/PreToolUse.json +14 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/SessionEnd.json +7 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/SessionStart.json +9 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/UserPromptSubmit.json +10 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/certification-provenance.json +66 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.0.json +19 -0
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +8 -1
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +20 -22
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +18 -11
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/certification.json +52 -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/extension/skills/acc/SKILL.md +20 -22
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeAgent.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool-shell.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionEnd.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionStart.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/certification-provenance.json +66 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +8 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +7 -3
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +4 -2
- package/node_modules/@agents-can-communicate/adapter-grok/certification.json +3 -0
- package/node_modules/@agents-can-communicate/adapter-grok/package.json +2 -1
- package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +20 -22
- package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +9 -9
- package/node_modules/@agents-can-communicate/adapter-kimi/certification.json +52 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Bash.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Write.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionHeartbeat.json +7 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionStart.json +9 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/UserPromptSubmit.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/certification-provenance.json +66 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +8 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +20 -22
- package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +7 -3
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +34 -18
- package/node_modules/@agents-can-communicate/adapter-sdk/src/certification.mjs +158 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +36 -17
- package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +7 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +13 -4
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +11 -30
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +3 -0
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -5
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +9 -2
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +100 -121
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/attention.mjs +106 -0
- package/node_modules/@agents-can-communicate/core/src/conversations.mjs +276 -0
- package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +81 -0
- package/node_modules/@agents-can-communicate/core/src/finish-retries.mjs +97 -0
- package/node_modules/@agents-can-communicate/core/src/inbox.mjs +91 -107
- package/node_modules/@agents-can-communicate/core/src/index.mjs +3 -3
- package/node_modules/@agents-can-communicate/core/src/intents.mjs +0 -1
- package/node_modules/@agents-can-communicate/core/src/ports.mjs +2 -1
- package/node_modules/@agents-can-communicate/core/src/receipts.mjs +109 -0
- package/node_modules/@agents-can-communicate/core/src/service.mjs +11 -10
- package/node_modules/@agents-can-communicate/core/src/sessions.mjs +22 -20
- package/node_modules/@agents-can-communicate/core/src/status.mjs +11 -9
- package/node_modules/@agents-can-communicate/core/src/sync.mjs +3 -294
- package/node_modules/@agents-can-communicate/delivery-router/package.json +12 -0
- package/node_modules/@agents-can-communicate/delivery-router/src/index.mjs +1 -0
- package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +117 -0
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +20 -0
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +115 -63
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +23 -7
- package/node_modules/@agents-can-communicate/installer/src/detect.mjs +20 -5
- package/node_modules/@agents-can-communicate/installer/src/index.mjs +3 -2
- package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +108 -12
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +19 -2
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/mcp-server/src/input-validator.mjs +79 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +23 -28
- package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +102 -72
- package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +54 -97
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/src/config.mjs +1 -1
- package/node_modules/@agents-can-communicate/protocol/src/conversations.mjs +64 -0
- package/node_modules/@agents-can-communicate/protocol/src/index.mjs +4 -1
- package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +49 -90
- package/node_modules/@agents-can-communicate/protocol/src/states.mjs +13 -40
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/active-journal.mjs +230 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +77 -28
- package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +83 -35
- package/node_modules/@agents-can-communicate/storage-filesystem/src/retention.mjs +112 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +18 -8
- package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +68 -26
- package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +113 -35
- package/package.json +19 -1
- package/node_modules/@agents-can-communicate/core/src/communication.mjs +0 -334
- package/node_modules/@agents-can-communicate/core/src/message-signals.mjs +0 -41
- package/node_modules/@agents-can-communicate/core/src/notify.mjs +0 -95
- package/node_modules/@agents-can-communicate/core/src/tasks.mjs +0 -244
- package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +0 -109
|
@@ -2,7 +2,6 @@ import { AccError, EXIT, SCHEMA_VERSION, assertPortableId, createId, validateRec
|
|
|
2
2
|
from "@agents-can-communicate/protocol";
|
|
3
3
|
|
|
4
4
|
import { ensureMaterialised, isMaterialised, materialise } from "./materialisation.mjs";
|
|
5
|
-
import { writeWorkResponse } from "./notify.mjs";
|
|
6
5
|
|
|
7
6
|
// A hook-only adapter heartbeats only when its harness gives it a turn, so the
|
|
8
7
|
// staleness window is a multiple of the cadence the session itself declared
|
|
@@ -154,7 +153,12 @@ export function createSessionService(ports) {
|
|
|
154
153
|
// The approved trigger is the SECOND live session, not the first: a lone
|
|
155
154
|
// session must be able to open and close without leaving a trace.
|
|
156
155
|
const live = (await store.ephemeral.list("session")).filter(item => item.state === "open");
|
|
157
|
-
|
|
156
|
+
// Recheck durable state only after our ephemeral writes. Another process
|
|
157
|
+
// may have staged the old ephemeral set after our first precheck, committed
|
|
158
|
+
// materialisation, and retired that set before these puts completed. In
|
|
159
|
+
// that case this attach is the only ephemeral session left, but it still
|
|
160
|
+
// has to run the idempotent promotion pass into the now-durable workspace.
|
|
161
|
+
if (live.length > 1 || await isMaterialised(store, workspaceId)) {
|
|
158
162
|
await materialise(ports, { workspaceId, descriptor: input.descriptor,
|
|
159
163
|
reason: "second_live_session" });
|
|
160
164
|
}
|
|
@@ -243,35 +247,33 @@ export function createSessionService(ports) {
|
|
|
243
247
|
|
|
244
248
|
await store.transaction(async tx => {
|
|
245
249
|
tx.put("session", sessionId, closed, tx.generationOf("session", sessionId));
|
|
246
|
-
// Work in progress goes back on the table. A task held by a session that
|
|
247
|
-
// has gone stayed `in_progress` forever, nobody else could take it, and
|
|
248
|
-
// whoever asked for it was never told - a request handed to an agent that
|
|
249
|
-
// closed its terminal simply vanished.
|
|
250
|
-
for (const task of tx.list("task")) {
|
|
251
|
-
if (task.assigneeSessionId !== sessionId) continue;
|
|
252
|
-
if (task.state === "done") continue;
|
|
253
|
-
const released = { ...task, assigneeSessionId: null, state: "pending" };
|
|
254
|
-
tx.put("task", task.taskId, released, tx.generationOf("task", task.taskId));
|
|
255
|
-
writeWorkResponse(tx, { task, actor: closed, workspaceId: closed.workspaceId,
|
|
256
|
-
now, ids, outcome: "released",
|
|
257
|
-
reason: "the session working on this closed before finishing it" });
|
|
258
|
-
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
|
|
259
|
-
workspaceId: closed.workspaceId, actorSessionId: sessionId,
|
|
260
|
-
type: "task.released", occurredAt: now, payload: { taskId: task.taskId } });
|
|
261
|
-
}
|
|
262
250
|
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
|
|
263
251
|
workspaceId: closed.workspaceId, actorSessionId: sessionId, type: "session.closed",
|
|
264
252
|
occurredAt: now, payload: {} });
|
|
265
|
-
|
|
266
|
-
}, { kinds: ["message", "receipt", "session", "task"] });
|
|
253
|
+
}, { kinds: ["session"] });
|
|
267
254
|
return closed;
|
|
268
255
|
}
|
|
269
256
|
|
|
257
|
+
async function listLiveSessions({ participantId, workspaceId, now = clock.now() }) {
|
|
258
|
+
const resolved = workspaceId ?? store.workspaceId;
|
|
259
|
+
const snapshot = resolved === undefined ? null
|
|
260
|
+
: await store.snapshot(resolved, { kinds: ["workspace", "session"] });
|
|
261
|
+
const records = snapshot?.workspace === null
|
|
262
|
+
? await store.ephemeral.list("session")
|
|
263
|
+
: snapshot?.sessions ?? await store.ephemeral.list("session");
|
|
264
|
+
return records
|
|
265
|
+
.filter(session => session.participantId === participantId
|
|
266
|
+
&& classifySessionPresence(session, now, pidIsAlive) !== "offline")
|
|
267
|
+
.sort((left, right) => left.sessionId.localeCompare(right.sessionId))
|
|
268
|
+
.map(session => ({ sessionId: session.sessionId, generation: session.generation }));
|
|
269
|
+
}
|
|
270
|
+
|
|
270
271
|
return {
|
|
271
272
|
openSession,
|
|
272
273
|
resumeSession,
|
|
273
274
|
heartbeatSession,
|
|
274
275
|
closeSession,
|
|
276
|
+
listLiveSessions,
|
|
275
277
|
locateSession: locate,
|
|
276
278
|
ensureMaterialised: options => ensureMaterialised(ports, options),
|
|
277
279
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { classifySessionPresence } from "./sessions.mjs";
|
|
2
|
-
import { computeAttention } from "./
|
|
2
|
+
import { computeAttention } from "./attention.mjs";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Protection level, reported from what is actually enforceable.
|
|
@@ -57,7 +57,7 @@ export function createGuardStateService(ports) {
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
export function createStatusService(ports, sessions) {
|
|
60
|
+
export function createStatusService(ports, sessions, deliveryBindings) {
|
|
61
61
|
const { store, clock, pidIsAlive } = ports;
|
|
62
62
|
|
|
63
63
|
async function collectStatus(input = {}) {
|
|
@@ -85,6 +85,7 @@ export function createStatusService(ports, sessions) {
|
|
|
85
85
|
const live = classified.filter(item => item.presence !== "offline");
|
|
86
86
|
const claims = snapshot.claims
|
|
87
87
|
.filter(claim => Date.parse(claim.expiresAt) > Date.parse(now));
|
|
88
|
+
const currentBindings = await deliveryBindings.currentBindings(now);
|
|
88
89
|
|
|
89
90
|
return {
|
|
90
91
|
workspaceId,
|
|
@@ -109,12 +110,6 @@ export function createStatusService(ports, sessions) {
|
|
|
109
110
|
presence,
|
|
110
111
|
intent: intents.find(intent => intent.sessionId === session.sessionId)?.summary ?? null,
|
|
111
112
|
})),
|
|
112
|
-
workstreams: snapshot.workstreams.map(workstream => ({
|
|
113
|
-
workstreamId: workstream.workstreamId,
|
|
114
|
-
title: workstream.title,
|
|
115
|
-
state: workstream.state,
|
|
116
|
-
coordinatorSessionId: workstream.coordinatorSessionId,
|
|
117
|
-
})),
|
|
118
113
|
// The owner is named twice on purpose. Every command that reaches a peer
|
|
119
114
|
// takes a participant id, so a claim that gave only a session id sent the
|
|
120
115
|
// reader back through the roster to answer "who is holding this, and how
|
|
@@ -133,13 +128,20 @@ export function createStatusService(ports, sessions) {
|
|
|
133
128
|
.find(session => session.sessionId === claim.ownerSessionId)?.participantId ?? null,
|
|
134
129
|
expiresAt: claim.expiresAt,
|
|
135
130
|
})),
|
|
131
|
+
deliveryBindings: currentBindings.map(({ binding, reachable }) => ({
|
|
132
|
+
adapterId: binding.adapterId,
|
|
133
|
+
clientVersion: binding.clientVersion,
|
|
134
|
+
availableModes: [...binding.availableModes],
|
|
135
|
+
livePolicy: binding.livePolicy,
|
|
136
|
+
reachable,
|
|
137
|
+
leaseUntil: binding.leaseUntil,
|
|
138
|
+
})),
|
|
136
139
|
attention: computeAttention(snapshot, { session: null,
|
|
137
140
|
participantId: input.participantId, now, pidIsAlive }),
|
|
138
141
|
counts: {
|
|
139
142
|
live: live.length,
|
|
140
143
|
stale: live.filter(item => item.presence === "stale").length,
|
|
141
144
|
claims: claims.length,
|
|
142
|
-
tasks: snapshot.tasks.length,
|
|
143
145
|
messages: snapshot.messages.length,
|
|
144
146
|
},
|
|
145
147
|
};
|
|
@@ -1,19 +1,10 @@
|
|
|
1
1
|
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
2
2
|
|
|
3
|
+
import { computeAttention } from "./attention.mjs";
|
|
3
4
|
import { classifySessionPresence } from "./sessions.mjs";
|
|
4
|
-
import { overlaps } from "./claims.mjs";
|
|
5
5
|
|
|
6
6
|
const DEFAULT_LIMIT = 100;
|
|
7
|
-
|
|
8
|
-
// The shape every event carries, and the only thing a caller may ask to resume
|
|
9
|
-
// from. `null` means the beginning, which is what a session with no cursor yet
|
|
10
|
-
// has.
|
|
11
7
|
const CURSOR = /^[0-9]{16}$/;
|
|
12
|
-
|
|
13
|
-
// The two a caller may ask for. An unknown one used to become `delta`, so
|
|
14
|
-
// `--scope ful` answered the one question the full scope exists for - "show me
|
|
15
|
-
// everything, I cannot see the rest of the system" - with a delta carrying no
|
|
16
|
-
// snapshot at all, and the agent concluded there was nothing to see.
|
|
17
8
|
const SCOPES = Object.freeze(["delta", "full"]);
|
|
18
9
|
|
|
19
10
|
function assertScope(scope) {
|
|
@@ -27,299 +18,22 @@ function assertCursor(cursor) {
|
|
|
27
18
|
if (typeof cursor !== "string" || !CURSOR.test(cursor)) {
|
|
28
19
|
throw new AccError(EXIT.USAGE,
|
|
29
20
|
"a cursor is the 16-digit sequence a previous sync returned; "
|
|
30
|
-
+ "leave it out to start from the beginning",
|
|
31
|
-
{ cursor });
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Attention is computed from explicit rules, never from a hidden classifier.
|
|
36
|
-
// Lower priority sorts first.
|
|
37
|
-
//
|
|
38
|
-
// Exported so a test can prove every kind listed here is reachable. A fifth
|
|
39
|
-
// entry once sat here with no rule behind it, which read as a feature in review
|
|
40
|
-
// and produced nothing at runtime.
|
|
41
|
-
export const ATTENTION_PRIORITY = Object.freeze({
|
|
42
|
-
direct_request: 1,
|
|
43
|
-
claim_conflict: 2,
|
|
44
|
-
task_unblocked: 3,
|
|
45
|
-
coordinator_missing: 4,
|
|
46
|
-
request_stalled: 5,
|
|
47
|
-
claim_expired: 6,
|
|
48
|
-
claim_contended: 7,
|
|
49
|
-
unread_note: 8,
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
function directRequests(snapshot, participantId) {
|
|
53
|
-
const items = [];
|
|
54
|
-
for (const receipt of snapshot.receipts ?? []) {
|
|
55
|
-
if (receipt.recipientParticipantId !== participantId) continue;
|
|
56
|
-
if (receipt.state === "acknowledged" || receipt.state === "failed") continue;
|
|
57
|
-
const message = (snapshot.messages ?? []).find(item => item.messageId === receipt.messageId);
|
|
58
|
-
if (message === undefined || !message.requiresAck) continue;
|
|
59
|
-
items.push({ kind: "direct_request", priority: ATTENTION_PRIORITY.direct_request,
|
|
60
|
-
sourceId: message.messageId, summary: message.subject });
|
|
61
|
-
}
|
|
62
|
-
return items;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* A note that was delivered once and then left unanswered.
|
|
67
|
-
*
|
|
68
|
-
* A `note` carries no acknowledgement obligation, so unlike a `requiresAck`
|
|
69
|
-
* message it raises no direct_request and, once injected, drops out of the
|
|
70
|
-
* inbox for good. Agents put decisions in notes anyway, and one delivered that
|
|
71
|
-
* way was missed for three hours because nothing stood behind it. This is the
|
|
72
|
-
* single low-priority breadcrumb that keeps a delivered-but-unacknowledged note
|
|
73
|
-
* recoverable: it fires only while the receipt reads `injected`, so the runner
|
|
74
|
-
* advancing it to `seen` after one showing makes it one-shot rather than a
|
|
75
|
-
* standing nag - the very noise a reader learns to skip. A `queued` note is
|
|
76
|
-
* about to be shown in full this turn and needs no breadcrumb yet.
|
|
77
|
-
*
|
|
78
|
-
* Only the recipient's, and named by message id so `acc inbox --message` or
|
|
79
|
-
* `acc ack --message` can act on it without a workspace-wide lookup.
|
|
80
|
-
*/
|
|
81
|
-
function unreadNotes(snapshot, participantId) {
|
|
82
|
-
const items = [];
|
|
83
|
-
for (const receipt of snapshot.receipts ?? []) {
|
|
84
|
-
if (receipt.recipientParticipantId !== participantId) continue;
|
|
85
|
-
if (receipt.state !== "injected") continue;
|
|
86
|
-
const message = (snapshot.messages ?? [])
|
|
87
|
-
.find(item => item.messageId === receipt.messageId);
|
|
88
|
-
// A requiresAck message already carries a standing direct_request; a second
|
|
89
|
-
// line here would be two reminders for one obligation.
|
|
90
|
-
if (message === undefined || message.requiresAck) continue;
|
|
91
|
-
items.push({ kind: "unread_note", priority: ATTENTION_PRIORITY.unread_note,
|
|
92
|
-
sourceId: message.messageId,
|
|
93
|
-
summary: `a note you have not acknowledged - \`acc inbox --message `
|
|
94
|
-
+ `${message.messageId}\` to read it` });
|
|
21
|
+
+ "leave it out to start from the beginning", { cursor });
|
|
95
22
|
}
|
|
96
|
-
return items;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* A claim of yours that has run out.
|
|
101
|
-
*
|
|
102
|
-
* A lease lapses on the clock, and nothing said so. Measured: while it held, a
|
|
103
|
-
* peer's write into the file was refused; three seconds later the same write
|
|
104
|
-
* went through, and the holder's turn was identical before and after. It went on
|
|
105
|
-
* working on a file it believed it had reserved, and everyone else was free to
|
|
106
|
-
* change it.
|
|
107
|
-
*
|
|
108
|
-
* Only for the session that took it, and only while that session is the one
|
|
109
|
-
* asking: a lapsed claim is news to its owner and nobody else's business.
|
|
110
|
-
* Re-claiming refreshes the lease and clears this; releasing it clears it too.
|
|
111
|
-
*/
|
|
112
|
-
function expiredClaims(snapshot, session, now) {
|
|
113
|
-
if (session == null) return [];
|
|
114
|
-
return (snapshot.claims ?? [])
|
|
115
|
-
.filter(claim => claim.ownerSessionId === session.sessionId
|
|
116
|
-
&& Date.parse(claim.expiresAt) <= Date.parse(now))
|
|
117
|
-
.map(claim => ({ kind: "claim_expired", priority: ATTENTION_PRIORITY.claim_expired,
|
|
118
|
-
sourceId: claim.claimId,
|
|
119
|
-
summary: `${claim.resource} - your claim has run out, and peers can write to it` }));
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function claimConflicts(snapshot, session, now) {
|
|
123
|
-
const mine = (snapshot.intents ?? []).find(intent => intent.sessionId === session?.sessionId);
|
|
124
|
-
if (mine === undefined) return [];
|
|
125
|
-
return (snapshot.claims ?? [])
|
|
126
|
-
.filter(claim => claim.ownerSessionId !== session.sessionId
|
|
127
|
-
&& Date.parse(claim.expiresAt) > Date.parse(now)
|
|
128
|
-
&& mine.resourceHints.some(hint => overlaps(hint, claim.resource)))
|
|
129
|
-
.map(claim => ({ kind: "claim_conflict", priority: ATTENTION_PRIORITY.claim_conflict,
|
|
130
|
-
sourceId: claim.claimId,
|
|
131
|
-
summary: `${claim.resource} is claimed by ${claim.ownerSessionId}` }));
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* A resource I hold that a peer has said they intend to touch.
|
|
136
|
-
*
|
|
137
|
-
* The mirror of `claimConflicts`. That one reads my own intent and warns me when
|
|
138
|
-
* what I mean to touch is already claimed. This one reads a peer's intent and
|
|
139
|
-
* warns me, the holder, that someone is heading for what I claimed. Without it
|
|
140
|
-
* intent's only wired reader faced inward: it protected the one declaring intent
|
|
141
|
-
* and told the claim holder nothing, so a claim was a wall nobody was told they
|
|
142
|
-
* were walking into. A claim is advisory - it does not stop the write - so being
|
|
143
|
-
* told early is the whole of the protection it offers.
|
|
144
|
-
*
|
|
145
|
-
* Only my own claims, and never my own intent against them: declaring intent on
|
|
146
|
-
* what you already hold is not someone reaching for it. The peer is named by
|
|
147
|
-
* participant where the roster knows it, because a session id cannot be used
|
|
148
|
-
* with `--to` and an id a reader cannot act on is the trap the projector warns of.
|
|
149
|
-
*/
|
|
150
|
-
function claimContended(snapshot, session, now) {
|
|
151
|
-
if (session === null || session === undefined) return [];
|
|
152
|
-
const theirs = (snapshot.intents ?? [])
|
|
153
|
-
.filter(intent => intent.sessionId !== session.sessionId);
|
|
154
|
-
const nameOf = sessionId => (snapshot.sessions ?? [])
|
|
155
|
-
.find(item => item.sessionId === sessionId)?.participantId ?? "a peer";
|
|
156
|
-
return (snapshot.claims ?? [])
|
|
157
|
-
.filter(claim => claim.ownerSessionId === session.sessionId
|
|
158
|
-
&& Date.parse(claim.expiresAt) > Date.parse(now))
|
|
159
|
-
.flatMap(claim => {
|
|
160
|
-
const eyeing = theirs.find(intent =>
|
|
161
|
-
(intent.resourceHints ?? []).some(hint => overlaps(hint, claim.resource)));
|
|
162
|
-
return eyeing === undefined ? [] : [{
|
|
163
|
-
kind: "claim_contended", priority: ATTENTION_PRIORITY.claim_contended,
|
|
164
|
-
sourceId: claim.claimId,
|
|
165
|
-
summary: `${claim.resource} - ${nameOf(eyeing.sessionId)} means to work on what you hold` }];
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Work waiting on me.
|
|
171
|
-
*
|
|
172
|
-
* Addressed by participant, so a request survives the recipient restarting -
|
|
173
|
-
* the next session of that agent is told about it. A task already taken by one
|
|
174
|
-
* of my sessions matches too, since that session may have been replaced.
|
|
175
|
-
*
|
|
176
|
-
* Unaddressed tasks are deliberately absent. Anyone may take one, but pushing
|
|
177
|
-
* every open task into every turn is how a coordination layer becomes noise.
|
|
178
|
-
*/
|
|
179
|
-
function unblockedTasks(snapshot, session, participantId) {
|
|
180
|
-
const mine = task => (task.assigneeParticipantId !== null
|
|
181
|
-
&& task.assigneeParticipantId === participantId)
|
|
182
|
-
|| (task.assigneeSessionId !== null && task.assigneeSessionId === session?.sessionId);
|
|
183
|
-
return (snapshot.tasks ?? [])
|
|
184
|
-
.filter(task => task.state === "pending" && mine(task))
|
|
185
|
-
.map(task => ({ kind: "task_unblocked", priority: ATTENTION_PRIORITY.task_unblocked,
|
|
186
|
-
sourceId: task.taskId, summary: task.title }));
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Work you asked for that nobody is doing any more.
|
|
191
|
-
*
|
|
192
|
-
* A task taken by a session that then crashed stayed `in_progress` for good:
|
|
193
|
-
* the requester was told nothing and nobody else could take it. Unlike the
|
|
194
|
-
* one-shot answers a request produces, this repeats until it is resolved,
|
|
195
|
-
* because it stays true until someone picks the work back up.
|
|
196
|
-
*/
|
|
197
|
-
/**
|
|
198
|
-
* A question nobody is left to answer.
|
|
199
|
-
*
|
|
200
|
-
* The task rule below tells a requester when work they asked for is going
|
|
201
|
-
* nowhere. A `requiresAck` message had no such rule, and a message is the other
|
|
202
|
-
* half of the same act: an agent asked a peer a direct question, the peer's
|
|
203
|
-
* session ended without answering, and the asker's next turn was empty. Not
|
|
204
|
-
* "still waiting" - empty. Measured, with the only other agent gone and an
|
|
205
|
-
* unanswered question standing between them.
|
|
206
|
-
*
|
|
207
|
-
* The same kind as the task case, because it is the same fact about the world:
|
|
208
|
-
* you asked, and there is nobody there.
|
|
209
|
-
*/
|
|
210
|
-
function unansweredQuestions(snapshot, participantId, onlineParticipants) {
|
|
211
|
-
const items = [];
|
|
212
|
-
for (const receipt of snapshot.receipts ?? []) {
|
|
213
|
-
if (receipt.state === "acknowledged" || receipt.state === "failed") continue;
|
|
214
|
-
const message = (snapshot.messages ?? [])
|
|
215
|
-
.find(item => item.messageId === receipt.messageId);
|
|
216
|
-
if (message === undefined || !message.requiresAck) continue;
|
|
217
|
-
if (message.fromParticipantId !== participantId) continue;
|
|
218
|
-
// Not answered yet by someone who is here is ordinary waiting, and saying so
|
|
219
|
-
// every turn would be noise the reader learns to skip.
|
|
220
|
-
if (onlineParticipants.has(receipt.recipientParticipantId)) continue;
|
|
221
|
-
items.push({ kind: "request_stalled", priority: ATTENTION_PRIORITY.request_stalled,
|
|
222
|
-
sourceId: message.messageId,
|
|
223
|
-
summary: `${message.subject} - ${receipt.recipientParticipantId} is not here to answer` });
|
|
224
|
-
}
|
|
225
|
-
return items;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
function stalledRequests(snapshot, participantId, now, pidIsAlive) {
|
|
229
|
-
// Classified once per session and reused below, for the same reason
|
|
230
|
-
// collectStatus takes one reading: classifySessionPresence calls pidIsAlive,
|
|
231
|
-
// a real process.kill(pid, 0) syscall for a session with a recorded pid, so
|
|
232
|
-
// it is not pure given `now` any more. A second classifying pass here could
|
|
233
|
-
// disagree with the first - a client exiting between them would leave `live`
|
|
234
|
-
// saying "online" while a freshly-computed `onlineParticipants` had already
|
|
235
|
-
// dropped it, inside one attention computation.
|
|
236
|
-
const live = new Map((snapshot.sessions ?? [])
|
|
237
|
-
.map(session => [session.sessionId, classifySessionPresence(session, now, pidIsAlive)]));
|
|
238
|
-
const onlineParticipants = new Set((snapshot.sessions ?? [])
|
|
239
|
-
.filter(session => live.get(session.sessionId) === "online")
|
|
240
|
-
.map(session => session.participantId));
|
|
241
|
-
const goingNowhere = task => {
|
|
242
|
-
// Taken by someone who has gone quiet.
|
|
243
|
-
if (task.state === "in_progress") {
|
|
244
|
-
return task.assigneeSessionId !== null
|
|
245
|
-
&& live.get(task.assigneeSessionId) !== "online";
|
|
246
|
-
}
|
|
247
|
-
// Or waiting on an agent that is not here - including one that closed and
|
|
248
|
-
// never came back, which leaves the work addressed to nobody at all.
|
|
249
|
-
return task.state === "pending" && task.assigneeParticipantId !== null
|
|
250
|
-
&& !onlineParticipants.has(task.assigneeParticipantId);
|
|
251
|
-
};
|
|
252
|
-
return [
|
|
253
|
-
...(snapshot.tasks ?? [])
|
|
254
|
-
.filter(task => task.requestedByParticipantId === participantId
|
|
255
|
-
&& goingNowhere(task))
|
|
256
|
-
.map(task => ({ kind: "request_stalled", priority: ATTENTION_PRIORITY.request_stalled,
|
|
257
|
-
sourceId: task.taskId,
|
|
258
|
-
summary: `${task.title} - nobody is working on it` })),
|
|
259
|
-
...unansweredQuestions(snapshot, participantId, onlineParticipants),
|
|
260
|
-
];
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
function coordinatorGaps(snapshot) {
|
|
264
|
-
return (snapshot.workstreams ?? [])
|
|
265
|
-
.filter(workstream => workstream.state === "open"
|
|
266
|
-
&& workstream.coordinatorSessionId === null)
|
|
267
|
-
.map(workstream => ({ kind: "coordinator_missing",
|
|
268
|
-
priority: ATTENTION_PRIORITY.coordinator_missing,
|
|
269
|
-
sourceId: workstream.workstreamId, summary: workstream.title }));
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
export function computeAttention(snapshot, { session, participantId, now, pidIsAlive }) {
|
|
273
|
-
// Required unconditionally, not only when there happen to be sessions to
|
|
274
|
-
// classify: `stalledRequests` reaches `classifySessionPresence` only inside a
|
|
275
|
-
// map/filter over `snapshot.sessions`, so an empty or absent roster let a
|
|
276
|
-
// missing probe through with nothing to trip over it - the same silent pass
|
|
277
|
-
// the classifier's own required parameter exists to close, one layer up.
|
|
278
|
-
if (typeof pidIsAlive !== "function") {
|
|
279
|
-
throw new AccError(EXIT.USAGE, "computeAttention requires a pidIsAlive probe", {});
|
|
280
|
-
}
|
|
281
|
-
return [
|
|
282
|
-
...directRequests(snapshot, participantId),
|
|
283
|
-
...unreadNotes(snapshot, participantId),
|
|
284
|
-
...claimConflicts(snapshot, session, now),
|
|
285
|
-
...claimContended(snapshot, session, now),
|
|
286
|
-
...expiredClaims(snapshot, session, now),
|
|
287
|
-
...unblockedTasks(snapshot, session, participantId),
|
|
288
|
-
...coordinatorGaps(snapshot),
|
|
289
|
-
...stalledRequests(snapshot, participantId, now, pidIsAlive),
|
|
290
|
-
].sort((left, right) => left.priority - right.priority
|
|
291
|
-
|| left.sourceId.localeCompare(right.sourceId));
|
|
292
23
|
}
|
|
293
24
|
|
|
294
25
|
export function createSyncService(ports, sessions) {
|
|
295
26
|
const { store, clock, pidIsAlive } = ports;
|
|
296
27
|
|
|
297
|
-
/**
|
|
298
|
-
* Any session may request the full Workspace scope. Peer equality is a
|
|
299
|
-
* knowledge property: no session receives a reduced
|
|
300
|
-
* view because of its role. The bounded delta is only the ambient default.
|
|
301
|
-
*/
|
|
302
28
|
async function sync(input = {}) {
|
|
303
|
-
// A cursor that is not a cursor answered "nothing new", every time, for as
|
|
304
|
-
// long as it was held. `eventsSince` compares sequences as strings, so
|
|
305
|
-
// `not-a-cursor` sorts after every event there has ever been - and an
|
|
306
|
-
// adapter holding a corrupt one, or an agent that invented one, saw a quiet
|
|
307
|
-
// workspace rather than a mistake. `"0000000000000001; DROP"` was quietly
|
|
308
|
-
// taken as the sequence it starts with.
|
|
309
29
|
if (input.cursor != null) assertCursor(input.cursor);
|
|
310
30
|
assertScope(input.scope);
|
|
311
31
|
const workspaceId = input.workspaceId ?? store.workspaceId;
|
|
312
32
|
const now = clock.now();
|
|
313
33
|
const located = input.sessionId === undefined
|
|
314
|
-
? null
|
|
315
|
-
: await sessions.locateSession(input.sessionId, workspaceId);
|
|
34
|
+
? null : await sessions.locateSession(input.sessionId, workspaceId);
|
|
316
35
|
const session = located?.record ?? null;
|
|
317
|
-
|
|
318
36
|
const durable = await store.snapshot(workspaceId);
|
|
319
|
-
// A workspace that has not materialised still has a truthful roster: its
|
|
320
|
-
// sessions live in the ephemeral area. Reading only the durable snapshot
|
|
321
|
-
// would make a lone session invisible to itself, and would disagree with
|
|
322
|
-
// what `status` reports from the same state.
|
|
323
37
|
const snapshot = durable.workspace !== null
|
|
324
38
|
? durable
|
|
325
39
|
: { ...durable,
|
|
@@ -329,7 +43,6 @@ export function createSyncService(ports, sessions) {
|
|
|
329
43
|
input.limit ?? DEFAULT_LIMIT);
|
|
330
44
|
const attention = computeAttention(snapshot, { session,
|
|
331
45
|
participantId: session?.participantId ?? input.participantId, now, pidIsAlive });
|
|
332
|
-
|
|
333
46
|
const roster = snapshot.sessions.map(item => ({
|
|
334
47
|
sessionId: item.sessionId,
|
|
335
48
|
participantId: item.participantId,
|
|
@@ -338,14 +51,10 @@ export function createSyncService(ports, sessions) {
|
|
|
338
51
|
branch: item.branch ?? null,
|
|
339
52
|
presence: classifySessionPresence(item, now, pidIsAlive),
|
|
340
53
|
}));
|
|
341
|
-
|
|
342
|
-
// Solo zero-overhead: one live session, no claims and
|
|
343
|
-
// no attention means an empty result, not a "nothing to report" banner.
|
|
344
54
|
const peers = roster.filter(item => item.sessionId !== session?.sessionId
|
|
345
55
|
&& item.presence !== "offline");
|
|
346
56
|
const solo = peers.length === 0 && attention.length === 0
|
|
347
57
|
&& snapshot.claims.length === 0;
|
|
348
|
-
|
|
349
58
|
return {
|
|
350
59
|
cursor: page.cursor,
|
|
351
60
|
scope: input.scope === "full" ? "full" : "delta",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createDeliveryRouter } from "./router.mjs";
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { effectiveCapabilities } from "@agents-can-communicate/adapter-sdk";
|
|
2
|
+
|
|
3
|
+
const PLATFORM = `${process.platform}-${process.arch}`;
|
|
4
|
+
const SAFE_ERRORS = new Set(["ambiguous_recipient_sessions", "delivery_disabled",
|
|
5
|
+
"recipient_busy", "recipient_unavailable", "transport_error", "transport_rejected",
|
|
6
|
+
"unsupported_client_version"]);
|
|
7
|
+
const NAMED_LIVE_TRANSPORTS = new Set(["claude-channel", "codex-app-server"]);
|
|
8
|
+
|
|
9
|
+
const adaptersById = adapters => adapters instanceof Map
|
|
10
|
+
? adapters
|
|
11
|
+
: new Map((Array.isArray(adapters) ? adapters : Object.values(adapters ?? {}))
|
|
12
|
+
.map(adapter => [adapter.id, adapter]));
|
|
13
|
+
|
|
14
|
+
const permits = (policy, kind) => policy === "all"
|
|
15
|
+
|| (policy === "actionable" && ["question", "request", "handoff"].includes(kind));
|
|
16
|
+
|
|
17
|
+
const durable = (recipientParticipantId, errorCode) => ({ recipientParticipantId,
|
|
18
|
+
outcome: "queued", transport: "durable", errorCode });
|
|
19
|
+
|
|
20
|
+
const settled = receipt => ({ recipientParticipantId: receipt.recipientParticipantId,
|
|
21
|
+
outcome: receipt.state, transport: "durable" });
|
|
22
|
+
|
|
23
|
+
function safeTransport(value, opaqueEndpointRef) {
|
|
24
|
+
if (NAMED_LIVE_TRANSPORTS.has(value) && value !== opaqueEndpointRef) return value;
|
|
25
|
+
// Both markers are fixed router vocabulary. The alternate prevents even a
|
|
26
|
+
// coincidental endpoint value equal to the primary redaction from escaping.
|
|
27
|
+
return opaqueEndpointRef === "live-adapter" ? "native-live" : "live-adapter";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createDeliveryRouter({ service, adapters, clock }) {
|
|
31
|
+
const registry = adaptersById(adapters);
|
|
32
|
+
|
|
33
|
+
async function recordFailure(binding, message, participantId, transport, safeErrorCode) {
|
|
34
|
+
await service.recordOfferFailed({ messageId: message.messageId,
|
|
35
|
+
recipientParticipantId: participantId, targetSessionId: binding.sessionId,
|
|
36
|
+
targetGeneration: binding.generation,
|
|
37
|
+
transport: safeTransport(transport, binding.opaqueEndpointRef), adapterId: binding.adapterId,
|
|
38
|
+
clientVersion: binding.clientVersion, safeErrorCode }).catch(() => null);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function offerTo(message, participantId, now) {
|
|
42
|
+
const receipt = await service.readReceipt({ messageId: message.messageId,
|
|
43
|
+
recipientParticipantId: participantId });
|
|
44
|
+
if (receipt.state !== "queued") return settled(receipt);
|
|
45
|
+
const liveSessions = await service.listLiveSessions({ participantId, now });
|
|
46
|
+
if (liveSessions.length === 0) return durable(participantId, "recipient_unavailable");
|
|
47
|
+
if (liveSessions.length > 1) {
|
|
48
|
+
return durable(participantId, "ambiguous_recipient_sessions");
|
|
49
|
+
}
|
|
50
|
+
const [target] = liveSessions;
|
|
51
|
+
const bindings = (await service.listDeliveryBindings({ participantId, now }))
|
|
52
|
+
.filter(binding => binding.sessionId === target.sessionId
|
|
53
|
+
&& binding.generation === target.generation);
|
|
54
|
+
if (bindings.length === 0) return durable(participantId, "recipient_unavailable");
|
|
55
|
+
const permitted = bindings.filter(binding => binding.livePolicy !== "off"
|
|
56
|
+
&& permits(binding.livePolicy, message.kind));
|
|
57
|
+
if (permitted.length === 0) return durable(participantId, "delivery_disabled");
|
|
58
|
+
const reachable = permitted.filter(binding => binding.availableModes.includes("livePush"));
|
|
59
|
+
if (reachable.length === 0) return durable(participantId, "recipient_unavailable");
|
|
60
|
+
const certified = reachable.map(binding => ({ binding,
|
|
61
|
+
adapter: registry.get(binding.adapterId) }))
|
|
62
|
+
.filter(({ binding, adapter }) => adapter !== undefined
|
|
63
|
+
&& effectiveCapabilities(adapter,
|
|
64
|
+
{ clientVersion: binding.clientVersion, platform: PLATFORM }).delivery.livePush);
|
|
65
|
+
if (certified.length === 0) {
|
|
66
|
+
return durable(participantId, "unsupported_client_version");
|
|
67
|
+
}
|
|
68
|
+
if (certified.length > 1) {
|
|
69
|
+
return durable(participantId, "ambiguous_recipient_sessions");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const { binding, adapter } = certified[0];
|
|
73
|
+
let response;
|
|
74
|
+
try {
|
|
75
|
+
response = await adapter.offerMessage({ binding, message });
|
|
76
|
+
} catch {
|
|
77
|
+
await recordFailure(binding, message, participantId, "live-adapter", "transport_error");
|
|
78
|
+
return durable(participantId, "transport_error");
|
|
79
|
+
}
|
|
80
|
+
const transport = safeTransport(response?.transport, binding.opaqueEndpointRef);
|
|
81
|
+
if (response?.accepted !== true) {
|
|
82
|
+
const code = SAFE_ERRORS.has(response?.safeErrorCode)
|
|
83
|
+
? response.safeErrorCode : "transport_rejected";
|
|
84
|
+
await recordFailure(binding, message, participantId, transport, code);
|
|
85
|
+
return durable(participantId, code);
|
|
86
|
+
}
|
|
87
|
+
if (response.clientVersion !== binding.clientVersion) {
|
|
88
|
+
await recordFailure(binding, message, participantId, transport,
|
|
89
|
+
"unsupported_client_version");
|
|
90
|
+
return durable(participantId, "unsupported_client_version");
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
await service.recordOfferSucceeded({ messageId: message.messageId,
|
|
94
|
+
recipientParticipantId: participantId, targetSessionId: binding.sessionId,
|
|
95
|
+
targetGeneration: binding.generation, transport, adapterId: binding.adapterId,
|
|
96
|
+
clientVersion: binding.clientVersion });
|
|
97
|
+
} catch {
|
|
98
|
+
await recordFailure(binding, message, participantId, transport, "transport_error");
|
|
99
|
+
return durable(participantId, "transport_error");
|
|
100
|
+
}
|
|
101
|
+
return { recipientParticipantId: participantId, outcome: "offered", transport };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function offer(message) {
|
|
105
|
+
if (!Array.isArray(message?.toParticipantIds) || message.toParticipantIds.length === 0) {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
const now = clock.now();
|
|
109
|
+
const outcomes = [];
|
|
110
|
+
for (const participantId of message.toParticipantIds) {
|
|
111
|
+
outcomes.push(await offerTo(message, participantId, now));
|
|
112
|
+
}
|
|
113
|
+
return outcomes;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return Object.freeze({ offer });
|
|
117
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
const VERSION = /(?:^|[^0-9A-Za-z])v?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)(?:\b|$)/;
|
|
4
|
+
|
|
5
|
+
export function parseClientVersion(output) {
|
|
6
|
+
return typeof output === "string" ? VERSION.exec(output)?.[1] ?? null : null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Probe the client binary once when its real session starts. */
|
|
10
|
+
export function probeClientVersion(adapter, { timeoutMs = 1_000 } = {}) {
|
|
11
|
+
return new Promise(resolve => {
|
|
12
|
+
execFile(adapter.client.command, adapter.client.versionArgs ?? ["--version"], {
|
|
13
|
+
timeout: timeoutMs,
|
|
14
|
+
windowsHide: true,
|
|
15
|
+
}, (error, stdout, stderr) => {
|
|
16
|
+
if (error !== null) return resolve(null);
|
|
17
|
+
resolve(parseClientVersion(`${stdout}${stderr}`));
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
}
|