@fitchmultz/pi-subagents 0.47.0 → 0.48.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/CHANGELOG.md +15 -3
- package/dist/extension/build-info.js +1 -1
- package/dist/pi-intercom/bridge-broadcast.js +60 -0
- package/dist/pi-intercom/bridge-catalog.js +372 -0
- package/dist/pi-intercom/bridge-config.js +14 -2
- package/dist/pi-intercom/bridge-control.js +104 -0
- package/dist/pi-intercom/bridge-groups.js +135 -0
- package/dist/pi-intercom/bridge-http.js +8 -1
- package/dist/pi-intercom/bridge-launch-journal.js +84 -0
- package/dist/pi-intercom/bridge-launch-policy.js +71 -5
- package/dist/pi-intercom/bridge-launch.js +186 -76
- package/dist/pi-intercom/bridge-operations.js +127 -0
- package/dist/pi-intercom/bridge-protocol.js +7 -0
- package/dist/pi-intercom/bridge-routes.js +171 -22
- package/dist/pi-intercom/bridge-rpc-process.js +43 -15
- package/dist/pi-intercom/bridge-session.js +24 -22
- package/dist/pi-intercom/bridge-target.js +22 -0
- package/dist/pi-intercom/bridge.js +29 -37
- package/dist/pi-intercom/broker/client.js +1 -0
- package/dist/pi-intercom/connection.js +2 -3
- package/dist/pi-intercom/inbound.js +2 -4
- package/dist/pi-intercom/index.js +7 -1
- package/dist/pi-intercom/native-control-auth.js +80 -0
- package/dist/pi-intercom/native-control-protocol.js +111 -0
- package/dist/pi-intercom/native-control.js +130 -0
- package/dist/pi-intercom/saved-transcript.js +135 -0
- package/dist/pi-intercom/session-targets.js +3 -0
- package/dist/pi-intercom/transcript.js +175 -0
- package/docs/intercom-bridge.md +285 -175
- package/docs/intercom.md +1 -1
- package/docs/reference.md +1 -1
- package/package.json +1 -1
- package/scripts/intercom-remote.mjs +176 -35
- package/src/pi-intercom/bridge-broadcast.ts +70 -0
- package/src/pi-intercom/bridge-catalog.ts +489 -0
- package/src/pi-intercom/bridge-config.ts +19 -2
- package/src/pi-intercom/bridge-control.ts +179 -0
- package/src/pi-intercom/bridge-groups.ts +169 -0
- package/src/pi-intercom/bridge-http.ts +15 -1
- package/src/pi-intercom/bridge-launch-journal.ts +122 -0
- package/src/pi-intercom/bridge-launch-policy.ts +88 -7
- package/src/pi-intercom/bridge-launch.ts +296 -107
- package/src/pi-intercom/bridge-operations.ts +169 -0
- package/src/pi-intercom/bridge-protocol.ts +8 -0
- package/src/pi-intercom/bridge-routes.ts +254 -42
- package/src/pi-intercom/bridge-rpc-process.ts +53 -16
- package/src/pi-intercom/bridge-session.ts +35 -36
- package/src/pi-intercom/bridge-target.ts +35 -0
- package/src/pi-intercom/bridge.ts +30 -47
- package/src/pi-intercom/broker/client.ts +3 -0
- package/src/pi-intercom/connection.ts +2 -3
- package/src/pi-intercom/inbound.ts +2 -4
- package/src/pi-intercom/index.ts +7 -1
- package/src/pi-intercom/native-control-auth.ts +89 -0
- package/src/pi-intercom/native-control-protocol.ts +180 -0
- package/src/pi-intercom/native-control.ts +160 -0
- package/src/pi-intercom/saved-transcript.ts +175 -0
- package/src/pi-intercom/session-targets.ts +4 -0
- package/src/pi-intercom/transcript.ts +226 -0
- package/src/pi-intercom/types.ts +2 -0
package/docs/intercom.md
CHANGED
|
@@ -605,7 +605,7 @@ Assistant-error replies wait for broker acknowledgement during `message_end`, bo
|
|
|
605
605
|
Historical official Pi 0.87.1 checks exposed queue visibility and prompt-preparation races. Current tests preserve exact-once receipts and input rejection/recovery on the selected host, including the genuine Pi 1.0 pre-admission window described above; they do not promise one combined provider turn. Historical results are not a current 1.0 pass. Use an explicit `async: false` wait when dependent work needs a finalized result. Native pending-call, idle-checkpoint and immediate-usage APIs are no longer supported. See [result delivery](reference.md#host-capabilities-and-result-delivery) and [local qualification](development.md#local-validation).
|
|
606
606
|
|
|
607
607
|
- **Same machine only** — The core broker uses local Unix sockets, no network support. The optional [secure remote bridge](intercom-bridge.md) authenticates remote messaging through a separate loopback mTLS proxy; it does not expose the broker socket.
|
|
608
|
-
- **No dedicated intercom log** — Messages are kept in Pi session history
|
|
608
|
+
- **No dedicated local intercom log** — Messages are kept in Pi session history. The optional remote bridge has its own durable mailbox and recent-transcript API; the local peer tool has no separate inbox.
|
|
609
609
|
- **No attachments UI** — `file`, `snippet`, and `context` attachments are supported in the protocol, but not in the compose overlay
|
|
610
610
|
- **Only connected sessions appear** — The list shows Pi sessions that have loaded `pi-intercom` and successfully registered with the broker, not every open Pi process on the machine
|
|
611
611
|
- **Broker lifecycle** — The broker auto-spawns on first use and exits when idle; sessions reconnect automatically if the broker restarts
|
package/docs/reference.md
CHANGED
|
@@ -375,7 +375,7 @@ The files in `prompts/` document common workflows without registering additional
|
|
|
375
375
|
|
|
376
376
|
## Bundled intercom
|
|
377
377
|
|
|
378
|
-
For
|
|
378
|
+
For an authenticated remote agent, the optional [secure remote bridge](intercom-bridge.md) provides a loopback-only mTLS HTTPS API carried by Tailscale TCP forwarding or an SSH reverse forward. It supports unattended messaging, durable mailboxes and wake notifications, named groups, live/saved session discovery, workspace/model-aware spawn, resume by stable ID or unique name, recent transcripts and native stop/cancel controls. The private Unix broker socket remains local. The guide explains setup, full credential authority, native host limits, safe updates, rotation and revocation.
|
|
379
379
|
|
|
380
380
|
The same `pi-subagents` install registers the intercom extension and skill. `load_intercom({})` exposes the full peer tool explicitly; actionable peer inbound and recovered reply needs also expose it before use. Presence, passive breadcrumbs, routine subagent notices, and human messages answered in chat do not. Broker presence and delivery continue even with the schema hidden. Managed children get a private coordination channel back to the parent Pi session unless an explicit agent extension allowlist excludes it. See [the intercom guide](intercom.md) for direct peer messaging, keyboard UI, tool actions, configuration, and broker details.
|
|
381
381
|
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@ import { lstatSync, readFileSync } from "node:fs";
|
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
5
|
|
|
6
6
|
function help() {
|
|
7
|
-
console.log(`Usage: node intercom-remote.mjs <register|list|inbox|send|ask|reply|ack|start|sessions|stop> [options] [IDs...]
|
|
7
|
+
console.log(`Usage: node intercom-remote.mjs <register|list|inbox|send|ask|reply|ack|start|spawn|resume|sessions|stop|cancel|capabilities|transcript|groups|group-upsert|broadcast|group-send> [options] [IDs...]
|
|
8
8
|
|
|
9
9
|
Environment (required):
|
|
10
10
|
INTERCOM_BRIDGE_CA Path to trusted bridge CA certificate
|
|
@@ -13,14 +13,27 @@ Environment (required):
|
|
|
13
13
|
INTERCOM_BRIDGE_URL HTTPS origin (default: https://127.0.0.1:9443)
|
|
14
14
|
|
|
15
15
|
Options:
|
|
16
|
-
--to PEER Recipient name or ID for
|
|
16
|
+
--to PEER Recipient name, peer ID or native ID for messages/controls
|
|
17
17
|
--message TEXT Message for send/ask/reply
|
|
18
18
|
--reply-to ID Inbound ask ID from inbox, for reply
|
|
19
|
-
--timeout-ms MS Ask
|
|
19
|
+
--timeout-ms MS Ask: 100..120000 (default 120000); control: 100..10000 (default 3000)
|
|
20
20
|
--after CURSOR Inbox cursor from nextCursor (default: 0)
|
|
21
|
-
--limit COUNT Inbox
|
|
21
|
+
--limit COUNT Inbox: 1..256; sessions/transcript: 1..100
|
|
22
22
|
--name NAME Start/reuse a named idle Pi session under local policy
|
|
23
|
-
--session-id ID
|
|
23
|
+
--session-id ID Native ID for resume, stop or cancel
|
|
24
|
+
--target ID_OR_NAME Unique target for resume, stop, cancel or transcript
|
|
25
|
+
--cwd PATH Spawn in an existing locally approved workspace
|
|
26
|
+
--task TEXT Submit an Intercom task after spawn/resume readiness
|
|
27
|
+
--model PROVIDER/ID Exact spawn model; or --provider NAME with --model ID
|
|
28
|
+
--mode cancel|kill Stop mode: owned kill observes exit; foreign kill requests shutdown
|
|
29
|
+
--live --saved Opt into live/saved session metadata
|
|
30
|
+
--q TEXT --offset N Catalog filter and page cursor
|
|
31
|
+
--max-bytes N Transcript UTF-8 text budget (256..131072)
|
|
32
|
+
--cursor CURSOR Opaque cursor from an earlier transcript page
|
|
33
|
+
--group NAME Named group for broadcast/group-send
|
|
34
|
+
--members JSON Group members as a JSON array of IDs/names
|
|
35
|
+
--name-pattern GLOB Dynamic group name selector
|
|
36
|
+
--cwd-pattern GLOB Dynamic group cwd selector (combined with name pattern)
|
|
24
37
|
-h, --help Show help
|
|
25
38
|
|
|
26
39
|
Examples:
|
|
@@ -31,7 +44,10 @@ Examples:
|
|
|
31
44
|
node intercom-remote.mjs reply --reply-to QUESTION_ID --message 'Proceed.'
|
|
32
45
|
node intercom-remote.mjs ack MESSAGE_ID
|
|
33
46
|
node intercom-remote.mjs start --name planner
|
|
34
|
-
node intercom-remote.mjs
|
|
47
|
+
node intercom-remote.mjs spawn --name planner --cwd /project --task 'Review this project.'
|
|
48
|
+
node intercom-remote.mjs sessions --live --saved
|
|
49
|
+
node intercom-remote.mjs resume --name saved-unique-name
|
|
50
|
+
node intercom-remote.mjs transcript --target NATIVE_SESSION_ID
|
|
35
51
|
node intercom-remote.mjs stop --session-id NATIVE_SESSION_ID
|
|
36
52
|
|
|
37
53
|
Outputs JSON. Exit: 0 success, 1 API/TLS/transport failure, 2 invalid arguments.
|
|
@@ -39,25 +55,24 @@ No automatic retries: a lost response does not prove a message was unsent.`);
|
|
|
39
55
|
}
|
|
40
56
|
|
|
41
57
|
function allowedOptions(action) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return [];
|
|
58
|
+
const fields = {
|
|
59
|
+
inbox: ["after", "limit"],
|
|
60
|
+
start: ["name"],
|
|
61
|
+
spawn: ["name", "cwd", "task", "provider", "model"],
|
|
62
|
+
resume: ["session-id", "name", "target", "task"],
|
|
63
|
+
stop: ["session-id", "target", "to", "mode", "timeout-ms"],
|
|
64
|
+
cancel: ["session-id", "target", "to", "timeout-ms"],
|
|
65
|
+
sessions: ["live", "saved", "q", "offset", "limit"],
|
|
66
|
+
capabilities: ["to", "target", "timeout-ms"],
|
|
67
|
+
transcript: ["to", "target", "timeout-ms", "limit", "max-bytes", "cursor"],
|
|
68
|
+
broadcast: ["message", "group"],
|
|
69
|
+
"group-send": ["message", "group"],
|
|
70
|
+
"group-upsert": ["name", "members", "name-pattern", "cwd-pattern"],
|
|
71
|
+
send: ["to", "message"],
|
|
72
|
+
ask: ["to", "message", "timeout-ms"],
|
|
73
|
+
reply: ["reply-to", "message"],
|
|
74
|
+
};
|
|
75
|
+
return fields[action] ?? [];
|
|
61
76
|
}
|
|
62
77
|
|
|
63
78
|
function requireMessage(recipient, message) {
|
|
@@ -71,15 +86,91 @@ function requireMessage(recipient, message) {
|
|
|
71
86
|
}
|
|
72
87
|
}
|
|
73
88
|
|
|
89
|
+
function resumeBody(options) {
|
|
90
|
+
if (["name", "session-id", "target"].filter((key) => options[key] !== undefined).length !== 1) {
|
|
91
|
+
throw new Error("Resume requires exactly one --name, --session-id or --target.");
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
name: options.name,
|
|
95
|
+
sessionId: options["session-id"],
|
|
96
|
+
target: options.target,
|
|
97
|
+
task: options.task,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function stopBody(action, options) {
|
|
101
|
+
if (["session-id", "target", "to"].filter((key) => options[key] !== undefined).length !== 1) {
|
|
102
|
+
throw new Error("Choose exactly one --session-id, --target or --to.");
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
target: options["session-id"] ?? options.target ?? options.to,
|
|
106
|
+
mode: action === "stop" ? options.mode : undefined,
|
|
107
|
+
timeoutMs: controlTimeout(options),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
74
110
|
function launchBody(action, options) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
111
|
+
if (action === "resume") {
|
|
112
|
+
return resumeBody(options);
|
|
113
|
+
}
|
|
114
|
+
if (action === "stop" || action === "cancel") {
|
|
115
|
+
return stopBody(action, options);
|
|
116
|
+
}
|
|
117
|
+
if (typeof options.name !== "string" || options.name.trim() === "") {
|
|
118
|
+
throw new Error("--name is required.");
|
|
119
|
+
}
|
|
120
|
+
return action === "start"
|
|
121
|
+
? { name: options.name }
|
|
122
|
+
: {
|
|
123
|
+
name: options.name,
|
|
124
|
+
cwd: options.cwd,
|
|
125
|
+
task: options.task,
|
|
126
|
+
provider: options.provider,
|
|
127
|
+
model: options.model,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function controlTimeout(options) {
|
|
131
|
+
const timeoutMs = options["timeout-ms"] === undefined ? 3000 : Number(options["timeout-ms"]);
|
|
132
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 10000) {
|
|
133
|
+
throw new Error("Control --timeout-ms must be 100..10000.");
|
|
134
|
+
}
|
|
135
|
+
return timeoutMs;
|
|
136
|
+
}
|
|
137
|
+
function extraBody(action, options) {
|
|
138
|
+
if (["capabilities", "transcript"].includes(action)) {
|
|
139
|
+
if (["to", "target"].filter((key) => options[key] !== undefined).length !== 1) {
|
|
140
|
+
throw new Error("Choose exactly one --to or --target.");
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
to: options.to ?? options.target,
|
|
144
|
+
timeoutMs: controlTimeout(options),
|
|
145
|
+
...(action === "transcript" ? transcriptBody(options) : {}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (action === "group-upsert") {
|
|
149
|
+
return {
|
|
150
|
+
name: options.name,
|
|
151
|
+
members: options.members === undefined ? undefined : JSON.parse(options.members),
|
|
152
|
+
namePattern: options["name-pattern"],
|
|
153
|
+
cwdPattern: options["cwd-pattern"],
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (action === "broadcast" || action === "group-send") {
|
|
157
|
+
requireMessage(action === "group-send" ? options.group : "all", options.message);
|
|
158
|
+
return { message: options.message, group: options.group };
|
|
78
159
|
}
|
|
79
|
-
|
|
160
|
+
}
|
|
161
|
+
function transcriptBody(options) {
|
|
162
|
+
return {
|
|
163
|
+
limit: options.limit === undefined ? undefined : Number(options.limit),
|
|
164
|
+
maxBytes: options["max-bytes"] === undefined ? undefined : Number(options["max-bytes"]),
|
|
165
|
+
cursor: options.cursor,
|
|
166
|
+
};
|
|
80
167
|
}
|
|
81
168
|
function requestBody(action, options, ids, timeout) {
|
|
82
|
-
|
|
169
|
+
const extra = extraBody(action, options);
|
|
170
|
+
if (extra !== undefined) {
|
|
171
|
+
return extra;
|
|
172
|
+
}
|
|
173
|
+
if (["start", "spawn", "resume", "stop", "cancel"].includes(action)) {
|
|
83
174
|
return launchBody(action, options);
|
|
84
175
|
}
|
|
85
176
|
if (["send", "ask", "reply"].includes(action)) {
|
|
@@ -139,6 +230,17 @@ function inboxQuery(options) {
|
|
|
139
230
|
return new URLSearchParams({ after, limit }).toString();
|
|
140
231
|
}
|
|
141
232
|
|
|
233
|
+
function requestQuery(action, options) {
|
|
234
|
+
if (action === "inbox") {
|
|
235
|
+
return inboxQuery(options);
|
|
236
|
+
}
|
|
237
|
+
return action === "sessions"
|
|
238
|
+
? new URLSearchParams(
|
|
239
|
+
Object.entries(options).map(([key, value]) => [key, String(value)]),
|
|
240
|
+
).toString()
|
|
241
|
+
: "";
|
|
242
|
+
}
|
|
243
|
+
|
|
142
244
|
let body, action, origin, timeout, query;
|
|
143
245
|
try {
|
|
144
246
|
const parsed = parseArgs({
|
|
@@ -153,6 +255,22 @@ try {
|
|
|
153
255
|
limit: { type: "string" },
|
|
154
256
|
name: { type: "string" },
|
|
155
257
|
"session-id": { type: "string" },
|
|
258
|
+
cwd: { type: "string" },
|
|
259
|
+
target: { type: "string" },
|
|
260
|
+
mode: { type: "string" },
|
|
261
|
+
provider: { type: "string" },
|
|
262
|
+
model: { type: "string" },
|
|
263
|
+
task: { type: "string" },
|
|
264
|
+
live: { type: "boolean" },
|
|
265
|
+
saved: { type: "boolean" },
|
|
266
|
+
q: { type: "string" },
|
|
267
|
+
offset: { type: "string" },
|
|
268
|
+
group: { type: "string" },
|
|
269
|
+
members: { type: "string" },
|
|
270
|
+
"name-pattern": { type: "string" },
|
|
271
|
+
"cwd-pattern": { type: "string" },
|
|
272
|
+
"max-bytes": { type: "string" },
|
|
273
|
+
cursor: { type: "string" },
|
|
156
274
|
},
|
|
157
275
|
});
|
|
158
276
|
if (parsed.values.help) {
|
|
@@ -171,8 +289,17 @@ try {
|
|
|
171
289
|
"reply",
|
|
172
290
|
"ack",
|
|
173
291
|
"start",
|
|
292
|
+
"spawn",
|
|
293
|
+
"resume",
|
|
174
294
|
"sessions",
|
|
175
295
|
"stop",
|
|
296
|
+
"cancel",
|
|
297
|
+
"capabilities",
|
|
298
|
+
"transcript",
|
|
299
|
+
"groups",
|
|
300
|
+
"group-upsert",
|
|
301
|
+
"broadcast",
|
|
302
|
+
"group-send",
|
|
176
303
|
].includes(action)
|
|
177
304
|
) {
|
|
178
305
|
throw new Error("Choose an action; use --help.");
|
|
@@ -189,7 +316,7 @@ try {
|
|
|
189
316
|
throw new Error("--timeout-ms must be 100..120000.");
|
|
190
317
|
}
|
|
191
318
|
body = requestBody(action, options, parsed.positionals.slice(1), timeout);
|
|
192
|
-
query = action
|
|
319
|
+
query = requestQuery(action, options);
|
|
193
320
|
origin = new URL(process.env.INTERCOM_BRIDGE_URL || "https://127.0.0.1:9443");
|
|
194
321
|
if (
|
|
195
322
|
origin.protocol !== "https:" ||
|
|
@@ -216,10 +343,11 @@ try {
|
|
|
216
343
|
|
|
217
344
|
try {
|
|
218
345
|
const files = tlsFiles();
|
|
219
|
-
|
|
346
|
+
const aliases = { "group-upsert": "groups", "group-send": "broadcast" };
|
|
347
|
+
origin.pathname = `/v1/${aliases[action] ?? action}`;
|
|
220
348
|
origin.search = query;
|
|
221
349
|
const payload = JSON.stringify(body);
|
|
222
|
-
const get = ["list", "inbox", "sessions"].includes(action);
|
|
350
|
+
const get = ["list", "inbox", "sessions", "groups"].includes(action);
|
|
223
351
|
const result = await new Promise((resolve, reject) => {
|
|
224
352
|
const req = https.request(
|
|
225
353
|
origin,
|
|
@@ -263,14 +391,21 @@ try {
|
|
|
263
391
|
() => {
|
|
264
392
|
req.destroy(new Error("Bridge request deadline exceeded; delivery may be unknown."));
|
|
265
393
|
},
|
|
266
|
-
|
|
394
|
+
["start", "spawn", "resume", "transcript"].includes(action)
|
|
395
|
+
? 90000
|
|
396
|
+
: (action === "ask" ? timeout : 0) + 15000,
|
|
267
397
|
);
|
|
268
398
|
req.once("close", () => clearTimeout(timer));
|
|
269
399
|
req.once("error", reject);
|
|
270
400
|
req.end(get ? undefined : payload);
|
|
271
401
|
});
|
|
272
402
|
console.log(JSON.stringify(result.value, null, 2));
|
|
273
|
-
if (
|
|
403
|
+
if (
|
|
404
|
+
result.status < 200 ||
|
|
405
|
+
result.status >= 300 ||
|
|
406
|
+
result.value?.ok !== true ||
|
|
407
|
+
unsuccessful(result.value)
|
|
408
|
+
) {
|
|
274
409
|
process.exitCode = 1;
|
|
275
410
|
}
|
|
276
411
|
} catch (error) {
|
|
@@ -286,3 +421,9 @@ try {
|
|
|
286
421
|
);
|
|
287
422
|
process.exitCode = 1;
|
|
288
423
|
}
|
|
424
|
+
function unsuccessful(value) {
|
|
425
|
+
return (
|
|
426
|
+
value?.status === "partial" ||
|
|
427
|
+
["denied", "failed", "unsupported"].includes(value?.native?.status)
|
|
428
|
+
);
|
|
429
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Fault, BroadcastFault } from "./bridge-protocol.ts";
|
|
3
|
+
import type { GroupTargets } from "./bridge-groups.ts";
|
|
4
|
+
import type { UnknownRecord } from "../shared/unknown.ts";
|
|
5
|
+
import type { Session } from "./bridge-session.ts";
|
|
6
|
+
import type { RequestContext } from "./bridge-http.ts";
|
|
7
|
+
|
|
8
|
+
export async function broadcastTargets(
|
|
9
|
+
session: Readonly<Session>,
|
|
10
|
+
targets: GroupTargets,
|
|
11
|
+
message: string,
|
|
12
|
+
context: RequestContext,
|
|
13
|
+
): Promise<UnknownRecord> {
|
|
14
|
+
const receipts: UnknownRecord[] = [];
|
|
15
|
+
for (const peer of targets.peers) {
|
|
16
|
+
if (context.signal.aborted) {
|
|
17
|
+
receipts.push({
|
|
18
|
+
peer: peer.id,
|
|
19
|
+
attempted: false,
|
|
20
|
+
error: {
|
|
21
|
+
code: "not_attempted",
|
|
22
|
+
message: "Broadcast stopped before sending.",
|
|
23
|
+
deliveryUnknown: false,
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const id = randomUUID();
|
|
29
|
+
try {
|
|
30
|
+
// No hidden retries; sequential admission bounds broker pressure.
|
|
31
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
32
|
+
const receipt = await session.send(peer.id, message, context, { messageId: id });
|
|
33
|
+
receipts.push({ peer: peer.id, attempted: true, ...receipt });
|
|
34
|
+
} catch (error) {
|
|
35
|
+
const fault =
|
|
36
|
+
error instanceof Fault
|
|
37
|
+
? error
|
|
38
|
+
: new Fault(503, "send_unconfirmed", "Target delivery could not be confirmed.");
|
|
39
|
+
receipts.push({
|
|
40
|
+
peer: peer.id,
|
|
41
|
+
attempted: true,
|
|
42
|
+
error: {
|
|
43
|
+
code: fault.code,
|
|
44
|
+
message: fault.message,
|
|
45
|
+
...fault.details,
|
|
46
|
+
id,
|
|
47
|
+
deliveryUnknown: fault.details.accepted !== false,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const failed =
|
|
53
|
+
receipts.filter((receipt) => receipt.accepted !== true).length + targets.errors.length;
|
|
54
|
+
const outcome = {
|
|
55
|
+
status: failed === 0 ? "complete" : "partial",
|
|
56
|
+
receipts,
|
|
57
|
+
errors: targets.errors,
|
|
58
|
+
};
|
|
59
|
+
// Transport closure must not erase outcomes, or disclose them after authorization is revoked.
|
|
60
|
+
session.authorize();
|
|
61
|
+
try {
|
|
62
|
+
session.guard(context.signal);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error instanceof Fault) {
|
|
65
|
+
throw new BroadcastFault(error, outcome);
|
|
66
|
+
}
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
return outcome;
|
|
70
|
+
}
|