agents-can-communicate 0.1.18 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -69
- package/SECURITY.md +31 -0
- package/bin/acc-bootstrap.mjs +56 -0
- package/bin/acc-claude-channel.mjs +177 -0
- package/bin/acc-hook.mjs +94 -12
- package/bin/acc-mcp.mjs +6 -2
- package/bin/acc.mjs +13 -3
- package/docs/ADAPTER_AUTHORING.md +204 -0
- package/docs/ARCHITECTURE.md +131 -0
- package/docs/CAPABILITIES.md +117 -214
- package/docs/CLI.md +164 -0
- package/docs/CONCEPTS.md +134 -0
- package/docs/CONFIGURATION.md +147 -0
- package/docs/DESIGN_DECISIONS.md +89 -0
- package/docs/GETTING_STARTED.md +145 -0
- package/docs/GLOSSARY.md +26 -0
- package/docs/HOW_IT_WORKS.md +277 -0
- package/docs/MCP.md +94 -0
- package/docs/PROTOCOL.md +200 -0
- package/docs/RELEASING.md +115 -0
- package/docs/SECURITY_MODEL.md +131 -0
- package/docs/TROUBLESHOOTING.md +108 -0
- package/docs/WHY_ACC.md +61 -0
- package/docs/index.md +44 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/certification.json +228 -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 +269 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.252.json +21 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.258.json +23 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.260.json +23 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +13 -2
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.mcp.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +22 -22
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +45 -5
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/channel.mjs +377 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +27 -7
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +229 -0
- package/node_modules/@agents-can-communicate/adapter-codex/certification.json +150 -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 +199 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.0.json +21 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-remote-workspace.json +25 -0
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +11 -2
- 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 +22 -22
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +54 -12
- package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +121 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +151 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +192 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/certification.json +68 -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 +22 -22
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeAgent-0.57.0.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool-0.57.0.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool-shell-0.57.0.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionEnd-0.57.0.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionStart-0.57.0.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/certification-provenance.json +293 -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 +31 -13
- 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 +22 -22
- package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +11 -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 +22 -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 +52 -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 +9 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +7 -2
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-activation.mjs +76 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +202 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +101 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +28 -4
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +12 -31
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +70 -5
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -5
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +111 -12
- 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 +131 -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 +21 -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 +131 -0
- package/node_modules/@agents-can-communicate/hook-runner/package.json +4 -2
- package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +20 -0
- package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +90 -0
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +190 -105
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +70 -10
- package/node_modules/@agents-can-communicate/installer/src/bootstrap-runtime.mjs +144 -0
- package/node_modules/@agents-can-communicate/installer/src/detect.mjs +89 -5
- package/node_modules/@agents-can-communicate/installer/src/index.mjs +10 -2
- package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +161 -0
- package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +112 -12
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +54 -2
- package/node_modules/@agents-can-communicate/installer/src/shell-bootstrap.mjs +210 -0
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/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/fields.mjs +17 -0
- package/node_modules/@agents-can-communicate/protocol/src/index.mjs +4 -1
- package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +64 -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 +20 -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
|
@@ -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,131 @@
|
|
|
1
|
+
const SAFE_ERRORS = new Set(["ambiguous_recipient_sessions", "delivery_disabled",
|
|
2
|
+
"recipient_busy", "recipient_unavailable", "transport_error", "transport_rejected",
|
|
3
|
+
"unsupported_client_version"]);
|
|
4
|
+
const NAMED_LIVE_TRANSPORTS = new Set(["claude-channel", "codex-app-server"]);
|
|
5
|
+
|
|
6
|
+
const adaptersById = adapters => adapters instanceof Map
|
|
7
|
+
? adapters
|
|
8
|
+
: new Map((Array.isArray(adapters) ? adapters : Object.values(adapters ?? {}))
|
|
9
|
+
.map(adapter => [adapter.id, adapter]));
|
|
10
|
+
|
|
11
|
+
// Everything that closes or advances a conversation is actionable: a question
|
|
12
|
+
// or request asks for work, an answer or decision resolves it, a handoff
|
|
13
|
+
// transfers it. A note informs and waits for the next turn. Room messages
|
|
14
|
+
// have no recipient and are never offered live.
|
|
15
|
+
const ACTIONABLE = new Set(["question", "request", "answer", "decision", "handoff"]);
|
|
16
|
+
const permits = (policy, kind) => policy === "all"
|
|
17
|
+
|| (policy === "actionable" && ACTIONABLE.has(kind));
|
|
18
|
+
|
|
19
|
+
// Compatibility was decided twice already - at bootstrap by the probe and at
|
|
20
|
+
// SessionStart by the generation-bound handshake that published this binding.
|
|
21
|
+
// The router validates binding identity and the adapter's answer; it does not
|
|
22
|
+
// impose a third, exact-version rule that would reject a client the handshake
|
|
23
|
+
// admitted.
|
|
24
|
+
const liveCapable = (adapter, binding) => adapter !== undefined
|
|
25
|
+
&& adapter.capabilities?.delivery?.livePush === true
|
|
26
|
+
&& adapter.nativeDelivery !== undefined
|
|
27
|
+
&& binding.availableModes.includes("livePush");
|
|
28
|
+
|
|
29
|
+
const durable = (recipientParticipantId, errorCode) => ({ recipientParticipantId,
|
|
30
|
+
outcome: "queued", transport: "durable", errorCode });
|
|
31
|
+
|
|
32
|
+
const settled = receipt => ({ recipientParticipantId: receipt.recipientParticipantId,
|
|
33
|
+
outcome: receipt.state, transport: "durable" });
|
|
34
|
+
|
|
35
|
+
function safeTransport(value, opaqueEndpointRef) {
|
|
36
|
+
if (NAMED_LIVE_TRANSPORTS.has(value) && value !== opaqueEndpointRef) return value;
|
|
37
|
+
// Both markers are fixed router vocabulary. The alternate prevents even a
|
|
38
|
+
// coincidental endpoint value equal to the primary redaction from escaping.
|
|
39
|
+
return opaqueEndpointRef === "live-adapter" ? "native-live" : "live-adapter";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createDeliveryRouter({ service, adapters, clock }) {
|
|
43
|
+
const registry = adaptersById(adapters);
|
|
44
|
+
|
|
45
|
+
async function recordFailure(binding, message, participantId, transport, safeErrorCode) {
|
|
46
|
+
await service.recordOfferFailed({ messageId: message.messageId,
|
|
47
|
+
recipientParticipantId: participantId, targetSessionId: binding.sessionId,
|
|
48
|
+
targetGeneration: binding.generation,
|
|
49
|
+
transport: safeTransport(transport, binding.opaqueEndpointRef), adapterId: binding.adapterId,
|
|
50
|
+
clientVersion: binding.clientVersion, safeErrorCode }).catch(() => null);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function offerTo(message, participantId, now) {
|
|
54
|
+
const receipt = await service.readReceipt({ messageId: message.messageId,
|
|
55
|
+
recipientParticipantId: participantId });
|
|
56
|
+
if (receipt.state !== "queued") return settled(receipt);
|
|
57
|
+
const liveSessions = await service.listLiveSessions({ participantId, now });
|
|
58
|
+
if (liveSessions.length === 0) return durable(participantId, "recipient_unavailable");
|
|
59
|
+
if (liveSessions.length > 1) {
|
|
60
|
+
return durable(participantId, "ambiguous_recipient_sessions");
|
|
61
|
+
}
|
|
62
|
+
const [target] = liveSessions;
|
|
63
|
+
const bindings = (await service.listDeliveryBindings({ participantId, now }))
|
|
64
|
+
.filter(binding => binding.sessionId === target.sessionId
|
|
65
|
+
&& binding.generation === target.generation);
|
|
66
|
+
if (bindings.length === 0) return durable(participantId, "recipient_unavailable");
|
|
67
|
+
const permitted = bindings.filter(binding => binding.livePolicy !== "off"
|
|
68
|
+
&& permits(binding.livePolicy, message.kind));
|
|
69
|
+
if (permitted.length === 0) return durable(participantId, "delivery_disabled");
|
|
70
|
+
const reachable = permitted.filter(binding => binding.availableModes.includes("livePush"));
|
|
71
|
+
if (reachable.length === 0) return durable(participantId, "recipient_unavailable");
|
|
72
|
+
const capable = reachable.map(binding => ({ binding,
|
|
73
|
+
adapter: registry.get(binding.adapterId) }))
|
|
74
|
+
.filter(({ binding, adapter }) => liveCapable(adapter, binding));
|
|
75
|
+
if (capable.length === 0) {
|
|
76
|
+
return durable(participantId, "unsupported_client_version");
|
|
77
|
+
}
|
|
78
|
+
if (capable.length > 1) {
|
|
79
|
+
return durable(participantId, "ambiguous_recipient_sessions");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const { binding, adapter } = capable[0];
|
|
83
|
+
let response;
|
|
84
|
+
try {
|
|
85
|
+
// The store root is this workspace's runtime dir; the adapter resolves its
|
|
86
|
+
// opaque endpoint id under it. Passed as data, never as a leak into core:
|
|
87
|
+
// the router does not read what the adapter does with it.
|
|
88
|
+
response = await adapter.offerMessage({ binding, message,
|
|
89
|
+
runtimeDir: service.store?.root });
|
|
90
|
+
} catch {
|
|
91
|
+
await recordFailure(binding, message, participantId, "live-adapter", "transport_error");
|
|
92
|
+
return durable(participantId, "transport_error");
|
|
93
|
+
}
|
|
94
|
+
const transport = safeTransport(response?.transport, binding.opaqueEndpointRef);
|
|
95
|
+
if (response?.accepted !== true) {
|
|
96
|
+
const code = SAFE_ERRORS.has(response?.safeErrorCode)
|
|
97
|
+
? response.safeErrorCode : "transport_rejected";
|
|
98
|
+
await recordFailure(binding, message, participantId, transport, code);
|
|
99
|
+
return durable(participantId, code);
|
|
100
|
+
}
|
|
101
|
+
if (response.clientVersion !== binding.clientVersion) {
|
|
102
|
+
await recordFailure(binding, message, participantId, transport,
|
|
103
|
+
"unsupported_client_version");
|
|
104
|
+
return durable(participantId, "unsupported_client_version");
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
await service.recordOfferSucceeded({ messageId: message.messageId,
|
|
108
|
+
recipientParticipantId: participantId, targetSessionId: binding.sessionId,
|
|
109
|
+
targetGeneration: binding.generation, transport, adapterId: binding.adapterId,
|
|
110
|
+
clientVersion: binding.clientVersion });
|
|
111
|
+
} catch {
|
|
112
|
+
await recordFailure(binding, message, participantId, transport, "transport_error");
|
|
113
|
+
return durable(participantId, "transport_error");
|
|
114
|
+
}
|
|
115
|
+
return { recipientParticipantId: participantId, outcome: "offered", transport };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function offer(message) {
|
|
119
|
+
if (!Array.isArray(message?.toParticipantIds) || message.toParticipantIds.length === 0) {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
const now = clock.now();
|
|
123
|
+
const outcomes = [];
|
|
124
|
+
for (const participantId of message.toParticipantIds) {
|
|
125
|
+
outcomes.push(await offerTo(message, participantId, now));
|
|
126
|
+
}
|
|
127
|
+
return outcomes;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return Object.freeze({ offer });
|
|
131
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agents-can-communicate/hook-runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
|
-
".": "./src/runner.mjs"
|
|
7
|
+
".": "./src/runner.mjs",
|
|
8
|
+
"./client-pid": "./src/client-pid.mjs",
|
|
9
|
+
"./process-table": "./src/process-table.mjs"
|
|
8
10
|
},
|
|
9
11
|
"files": [
|
|
10
12
|
"src/"
|
|
@@ -0,0 +1,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
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { validateNativeHandshake } from "@agents-can-communicate/adapter-sdk";
|
|
2
|
+
import { EXIT } from "@agents-can-communicate/protocol";
|
|
3
|
+
|
|
4
|
+
// The hook side of native delivery: one bounded, fail-open attempt to bind
|
|
5
|
+
// this exact ACC session generation to the vendor session the adapter can
|
|
6
|
+
// reach, then a sanitized binding published for the router. The helper, not
|
|
7
|
+
// the adapter, supplies adapter id, client facts, session id, and generation;
|
|
8
|
+
// the adapter supplies only closed handshake facts. Nothing here throws into
|
|
9
|
+
// the hook path, and no endpoint reference ever leaves through the result.
|
|
10
|
+
|
|
11
|
+
export const LIVE_POLICIES = Object.freeze(["off", "actionable", "all"]);
|
|
12
|
+
export const NATIVE_BINDING_STATES = Object.freeze(["active", "off", "degraded", "unsupported"]);
|
|
13
|
+
const DEFAULT_TIMEOUT_MS = 750;
|
|
14
|
+
const DEFAULT_CADENCE_MS = 60_000;
|
|
15
|
+
|
|
16
|
+
// Reasons the static rule refused: the client will not change within the
|
|
17
|
+
// session, so the state is "unsupported" rather than a retryable "degraded".
|
|
18
|
+
const STATIC_REASONS = new Set(["native_delivery_unsupported", "platform_not_captured",
|
|
19
|
+
"version_unavailable", "prerelease_not_captured", "below_minimum_version",
|
|
20
|
+
"known_bad_version"]);
|
|
21
|
+
|
|
22
|
+
// Only the value a successful owned shell bootstrap exported counts; anything
|
|
23
|
+
// else - missing, blank, differently cased, a number - is off.
|
|
24
|
+
export function livePolicyFrom(env) {
|
|
25
|
+
const value = env?.ACC_NATIVE_DELIVERY_POLICY;
|
|
26
|
+
return LIVE_POLICIES.includes(value) ? value : "off";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const isPid = value => Number.isInteger(value) && value > 0;
|
|
30
|
+
|
|
31
|
+
export async function establishNativeBinding({ adapter, event, hookBinding, clientVersion,
|
|
32
|
+
platform, livePolicy, service, runtimeDir, clock,
|
|
33
|
+
timeoutMs = DEFAULT_TIMEOUT_MS, heartbeatCadenceMs = DEFAULT_CADENCE_MS }) {
|
|
34
|
+
const outcome = (state, reasonCode, modes = []) =>
|
|
35
|
+
Object.freeze({ state, reasonCode, modes: Object.freeze([...modes]) });
|
|
36
|
+
const sessionId = hookBinding?.accSessionId;
|
|
37
|
+
const generation = hookBinding?.generation;
|
|
38
|
+
if (typeof sessionId !== "string" || typeof generation !== "string") return outcome("off", null);
|
|
39
|
+
const policy = LIVE_POLICIES.includes(livePolicy) ? livePolicy : "off";
|
|
40
|
+
const clear = () => service.clearDeliveryBinding({ sessionId, generation }).catch(() => null);
|
|
41
|
+
if (policy === "off") {
|
|
42
|
+
await clear();
|
|
43
|
+
return outcome("off", null);
|
|
44
|
+
}
|
|
45
|
+
if (adapter?.nativeDelivery === undefined || typeof adapter.bindNativeSession !== "function") {
|
|
46
|
+
return outcome("unsupported", "native_delivery_unsupported");
|
|
47
|
+
}
|
|
48
|
+
const clientPid = hookBinding.clientPid;
|
|
49
|
+
if (!isPid(clientPid)) {
|
|
50
|
+
await clear();
|
|
51
|
+
return outcome("degraded", "client_process_unknown");
|
|
52
|
+
}
|
|
53
|
+
// Whatever this generation published before is retired first, so a failed
|
|
54
|
+
// re-handshake can never leave yesterday's endpoint reachable.
|
|
55
|
+
await clear();
|
|
56
|
+
let timer = null;
|
|
57
|
+
try {
|
|
58
|
+
const budget = Math.max(1, Math.floor(timeoutMs));
|
|
59
|
+
const handshake = await Promise.race([
|
|
60
|
+
adapter.bindNativeSession({ event, clientPid, clientVersion, runtimeDir, timeoutMs: budget }),
|
|
61
|
+
new Promise((_resolve, reject) => {
|
|
62
|
+
timer = setTimeout(() => reject(Object.assign(new Error("native handshake timed out"),
|
|
63
|
+
{ code: "ETIMEDOUT" })), budget);
|
|
64
|
+
}),
|
|
65
|
+
]);
|
|
66
|
+
const verdict = validateNativeHandshake(adapter, { clientVersion, platform, handshake });
|
|
67
|
+
if (!verdict.ok) {
|
|
68
|
+
return outcome(STATIC_REASONS.has(verdict.reasonCode) ? "unsupported" : "degraded",
|
|
69
|
+
verdict.reasonCode);
|
|
70
|
+
}
|
|
71
|
+
const now = Date.parse(clock.now());
|
|
72
|
+
const lease = Date.parse(verdict.leaseUntil);
|
|
73
|
+
if (!(lease > now)) return outcome("degraded", "handshake_failed");
|
|
74
|
+
const ceiling = now + 2 * heartbeatCadenceMs;
|
|
75
|
+
const leaseUntil = lease > ceiling ? new Date(ceiling).toISOString() : verdict.leaseUntil;
|
|
76
|
+
await service.publishDeliveryBinding({
|
|
77
|
+
sessionId, generation, adapterId: adapter.id, clientVersion,
|
|
78
|
+
availableModes: [...verdict.modes], livePolicy: policy,
|
|
79
|
+
opaqueEndpointRef: verdict.opaqueEndpointRef, leaseUntil,
|
|
80
|
+
});
|
|
81
|
+
return outcome("active", null, verdict.modes);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
await clear();
|
|
84
|
+
const reasonCode = error?.code === "ETIMEDOUT" ? "handshake_timeout"
|
|
85
|
+
: error?.code === EXIT.CONFLICT ? "session_generation_stale" : "handshake_failed";
|
|
86
|
+
return outcome("degraded", reasonCode);
|
|
87
|
+
} finally {
|
|
88
|
+
if (timer !== null) clearTimeout(timer);
|
|
89
|
+
}
|
|
90
|
+
}
|