@fitchmultz/pi-subagents 0.47.0 → 0.49.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 +29 -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 +15 -3
- 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-mailbox.js +3 -55
- package/dist/pi-intercom/bridge-operations.js +127 -0
- package/dist/pi-intercom/bridge-presence.js +5 -1
- package/dist/pi-intercom/bridge-protocol.js +7 -0
- package/dist/pi-intercom/bridge-routes.js +195 -27
- package/dist/pi-intercom/bridge-rpc-process.js +43 -15
- package/dist/pi-intercom/bridge-session.js +83 -66
- package/dist/pi-intercom/bridge-storage.js +73 -0
- package/dist/pi-intercom/bridge-target.js +22 -0
- package/dist/pi-intercom/bridge-wake-owner.js +57 -0
- package/dist/pi-intercom/bridge-wake-state.js +293 -0
- package/dist/pi-intercom/bridge-wake.js +190 -99
- package/dist/pi-intercom/bridge.js +39 -39
- 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 +315 -174
- package/docs/intercom.md +1 -1
- package/docs/reference.md +1 -1
- package/package.json +1 -1
- package/scripts/intercom-bridge-wake-preflight.mjs +112 -0
- package/scripts/intercom-remote.mjs +213 -38
- 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 +20 -3
- 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-mailbox.ts +3 -59
- package/src/pi-intercom/bridge-operations.ts +169 -0
- package/src/pi-intercom/bridge-presence.ts +7 -1
- package/src/pi-intercom/bridge-protocol.ts +8 -0
- package/src/pi-intercom/bridge-routes.ts +291 -49
- package/src/pi-intercom/bridge-rpc-process.ts +53 -16
- package/src/pi-intercom/bridge-session.ts +98 -96
- package/src/pi-intercom/bridge-storage.ts +83 -0
- package/src/pi-intercom/bridge-target.ts +35 -0
- package/src/pi-intercom/bridge-wake-owner.ts +58 -0
- package/src/pi-intercom/bridge-wake-state.ts +321 -0
- package/src/pi-intercom/bridge-wake.ts +224 -130
- package/src/pi-intercom/bridge.ts +41 -49
- 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
|
@@ -1,146 +1,232 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { log } from "./bridge-protocol.js";
|
|
2
3
|
import { Mailbox } from "./bridge-mailbox.js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const
|
|
6
|
-
|
|
4
|
+
import { WakeState } from "./bridge-wake-state.js";
|
|
5
|
+
function actionable(entry) {
|
|
6
|
+
const message = entry.message;
|
|
7
|
+
if (entry.acknowledged || message.passive === true || message.delivery === "passive") {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
return (message.expectsReply === true ||
|
|
11
|
+
!/^(?:ACK|DUP-ACK|FYI|NOTE|RECEIPT|NOWAKE)(?=$|[\s:])/.test(message.content.text));
|
|
12
|
+
}
|
|
13
|
+
function bucket(identity) {
|
|
14
|
+
return createHash("sha256")
|
|
15
|
+
.update(identity.wake?.url ?? "")
|
|
16
|
+
.digest("hex");
|
|
17
|
+
}
|
|
18
|
+
/** Mailbox commits and durable retry/quota deadlines drive wakes; there is no intake scan timer. */
|
|
19
|
+
export class WakeDeliveries {
|
|
20
|
+
stateDir;
|
|
21
|
+
state;
|
|
22
|
+
allowed;
|
|
23
|
+
deliveries = new Map();
|
|
7
24
|
controller = new AbortController();
|
|
8
25
|
timer;
|
|
9
26
|
running;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
this.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
27
|
+
dirty = false;
|
|
28
|
+
storageBackoff = 15000;
|
|
29
|
+
constructor(stateDir, allowed) {
|
|
30
|
+
this.stateDir = stateDir;
|
|
31
|
+
this.allowed = allowed;
|
|
32
|
+
// Initialize before any presence proof/mailbox is created, so genuinely fresh installs start now.
|
|
33
|
+
this.state = stateDir === undefined ? undefined : new WakeState(stateDir);
|
|
34
|
+
}
|
|
35
|
+
session(identity, expires) {
|
|
36
|
+
if (!identity.wake ||
|
|
37
|
+
this.stateDir === undefined ||
|
|
38
|
+
this.deliveries.has(identity.fingerprint256)) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
this.deliveries.set(identity.fingerprint256, {
|
|
42
|
+
identity,
|
|
43
|
+
expires,
|
|
44
|
+
mailbox: new Mailbox(this.stateDir, identity.fingerprint256, true),
|
|
45
|
+
controller: new AbortController(),
|
|
46
|
+
});
|
|
47
|
+
// Startup restores every certificate proof synchronously before the scheduled reconciliation.
|
|
48
|
+
// An empty first fingerprint must not clear another retained fingerprint's durable cold batch.
|
|
49
|
+
this.changed(false);
|
|
50
|
+
}
|
|
51
|
+
changed(reconcile = true) {
|
|
52
|
+
if (this.controller.signal.aborted) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
// ACK may be followed by fresh mail before the event timer runs. Clear completed batches now
|
|
57
|
+
// so a new obligation does not inherit a completed batch's uncertainty/backoff.
|
|
58
|
+
if (reconcile) {
|
|
59
|
+
this.reconcile();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// The scheduled storage recovery fails closed; never let an event bypass durable admission.
|
|
64
|
+
}
|
|
65
|
+
if (this.running) {
|
|
66
|
+
this.dirty = true;
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
this.schedule(Date.now());
|
|
70
|
+
}
|
|
20
71
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
72
|
+
reconcile() {
|
|
73
|
+
for (const [key, candidate] of this.groups()) {
|
|
74
|
+
if (candidate) {
|
|
75
|
+
this.state?.deadline(key);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
this.state?.retire(key);
|
|
79
|
+
}
|
|
24
80
|
}
|
|
25
81
|
}
|
|
26
|
-
schedule(
|
|
82
|
+
schedule(deadline) {
|
|
27
83
|
clearTimeout(this.timer);
|
|
28
84
|
this.timer = setTimeout(() => {
|
|
85
|
+
this.dirty = false;
|
|
86
|
+
let next = Infinity;
|
|
29
87
|
this.running = this.deliver()
|
|
88
|
+
.then((due) => {
|
|
89
|
+
next = due;
|
|
90
|
+
this.storageBackoff = 15000;
|
|
91
|
+
return;
|
|
92
|
+
})
|
|
30
93
|
.catch(() => {
|
|
31
|
-
log("wake_delivery", {
|
|
32
|
-
|
|
94
|
+
log("wake_delivery", { result: "storage_unavailable" });
|
|
95
|
+
next = Date.now() + this.storageBackoff;
|
|
96
|
+
this.storageBackoff = Math.min(this.storageBackoff * 2, 300000);
|
|
33
97
|
})
|
|
34
98
|
.finally(() => {
|
|
35
99
|
this.running = undefined;
|
|
36
|
-
if (!this.controller.signal.aborted) {
|
|
37
|
-
this.schedule(this.
|
|
100
|
+
if (!this.controller.signal.aborted && (this.dirty || Number.isFinite(next))) {
|
|
101
|
+
this.schedule(this.dirty ? Date.now() : next);
|
|
38
102
|
}
|
|
39
103
|
});
|
|
40
|
-
},
|
|
104
|
+
}, Math.max(0, Math.min(deadline - Date.now(), 0x7fffffff)));
|
|
105
|
+
}
|
|
106
|
+
authorized(delivery) {
|
|
107
|
+
return (!this.controller.signal.aborted &&
|
|
108
|
+
this.allowed(delivery.identity) &&
|
|
109
|
+
delivery.expires > Date.now());
|
|
110
|
+
}
|
|
111
|
+
pending(delivery) {
|
|
112
|
+
let after = 0;
|
|
113
|
+
let next;
|
|
114
|
+
let page;
|
|
115
|
+
do {
|
|
116
|
+
page = delivery.mailbox.pendingNotifications(after);
|
|
117
|
+
for (const entry of page.messages) {
|
|
118
|
+
if (!actionable(entry)) {
|
|
119
|
+
// Retire only notification intent. Payload/reply obligations require actual ACK/reply.
|
|
120
|
+
delivery.mailbox.notificationAccepted(entry.message.id);
|
|
121
|
+
}
|
|
122
|
+
else if (!next) {
|
|
123
|
+
next = entry;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
after = Number(page.nextCursor);
|
|
127
|
+
} while (page.hasMore);
|
|
128
|
+
return next;
|
|
129
|
+
}
|
|
130
|
+
groups() {
|
|
131
|
+
const groups = new Map();
|
|
132
|
+
for (const delivery of this.deliveries.values()) {
|
|
133
|
+
if (!this.authorized(delivery)) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const key = bucket(delivery.identity);
|
|
137
|
+
const entry = this.pending(delivery);
|
|
138
|
+
const previous = groups.get(key);
|
|
139
|
+
if (!groups.has(key) ||
|
|
140
|
+
(entry && (!previous || entry.receivedAt < previous.entry.receivedAt))) {
|
|
141
|
+
groups.set(key, entry ? { delivery, entry } : undefined);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return groups;
|
|
41
145
|
}
|
|
42
146
|
async deliver() {
|
|
43
|
-
|
|
44
|
-
|
|
147
|
+
const state = this.state;
|
|
148
|
+
let next = Infinity;
|
|
149
|
+
if (!state) {
|
|
150
|
+
return next;
|
|
45
151
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
152
|
+
// ACK can remove the final candidate while its failed completion write still needs recovery.
|
|
153
|
+
state.retrySettlement();
|
|
154
|
+
for (const [key, candidate] of this.groups()) {
|
|
155
|
+
if (!candidate) {
|
|
156
|
+
state.retire(key);
|
|
157
|
+
continue;
|
|
51
158
|
}
|
|
159
|
+
const due = state.deadline(key);
|
|
160
|
+
if (due > Date.now()) {
|
|
161
|
+
next = Math.min(next, due);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const deferred = state.reserve(key);
|
|
165
|
+
if (deferred !== undefined) {
|
|
166
|
+
next = Math.min(next, deferred);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
// ponytail: two cold Macs can each admit one wake. Literal cross-Mac single admission
|
|
170
|
+
// requires a neutral quota authority governing every writer, not a URL hash or HTTP 2xx.
|
|
171
|
+
try {
|
|
172
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
173
|
+
await this.post(candidate);
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
// Expiry starts after response/error/abort, never before COMMIT or connect/TLS.
|
|
177
|
+
state.settle();
|
|
178
|
+
}
|
|
179
|
+
// Re-read ACKs and all groups after transport I/O; a snapshot must not wake already consumed mail.
|
|
180
|
+
return Date.now();
|
|
52
181
|
}
|
|
53
|
-
|
|
54
|
-
if (next) {
|
|
55
|
-
// One fallback wakes the box to drain the remaining mailbox; never flood a reset VM.
|
|
56
|
-
await this.post(next);
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
this.backoff = POLL_MS;
|
|
182
|
+
return next;
|
|
60
183
|
}
|
|
61
|
-
async post(entry) {
|
|
62
|
-
|
|
184
|
+
async post({ delivery, entry }) {
|
|
185
|
+
const policy = delivery.identity.wake;
|
|
186
|
+
if (!policy || !this.authorized(delivery)) {
|
|
63
187
|
return;
|
|
64
188
|
}
|
|
65
|
-
const fields = { name:
|
|
189
|
+
const fields = { name: delivery.identity.name, messageId: entry.message.id };
|
|
66
190
|
try {
|
|
67
|
-
const response = await fetch(
|
|
191
|
+
const response = await fetch(policy.url, {
|
|
68
192
|
method: "POST",
|
|
69
193
|
redirect: "error",
|
|
70
|
-
headers: {
|
|
71
|
-
Authorization: `Bearer ${this.policy.key}`,
|
|
72
|
-
"Content-Type": "application/json",
|
|
73
|
-
},
|
|
194
|
+
headers: { Authorization: `Bearer ${policy.key}`, "Content-Type": "application/json" },
|
|
74
195
|
body: JSON.stringify({
|
|
75
196
|
from: entry.from.name ?? entry.from.id,
|
|
76
197
|
from_id: entry.from.id,
|
|
77
|
-
to:
|
|
198
|
+
to: delivery.identity.name,
|
|
78
199
|
message_id: entry.message.id,
|
|
79
200
|
ask_id: entry.message.expectsReply === true ? entry.message.id : null,
|
|
80
201
|
text: entry.message.content.text,
|
|
81
202
|
}),
|
|
82
|
-
signal: AbortSignal.any([
|
|
203
|
+
signal: AbortSignal.any([
|
|
204
|
+
this.controller.signal,
|
|
205
|
+
delivery.controller.signal,
|
|
206
|
+
AbortSignal.timeout(10000),
|
|
207
|
+
]),
|
|
83
208
|
});
|
|
84
209
|
await response.body?.cancel();
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
|
|
92
|
-
log("wake_delivery", { ...fields, result: "http_rejected", status: response.status });
|
|
93
|
-
}
|
|
210
|
+
// Acceptance does not consume mail or reset uncertainty: only real inbox ACK retires intent.
|
|
211
|
+
log("wake_delivery", {
|
|
212
|
+
...fields,
|
|
213
|
+
result: response.ok ? "accepted" : "http_rejected",
|
|
214
|
+
status: response.status,
|
|
215
|
+
});
|
|
94
216
|
}
|
|
95
217
|
catch {
|
|
96
|
-
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
|
|
97
218
|
if (!this.controller.signal.aborted) {
|
|
98
219
|
log("wake_delivery", { ...fields, result: "transport_unconfirmed" });
|
|
99
220
|
}
|
|
100
221
|
}
|
|
101
222
|
}
|
|
102
|
-
async stop() {
|
|
103
|
-
this.controller.abort();
|
|
104
|
-
clearTimeout(this.timer);
|
|
105
|
-
await this.running;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
export class WakeDeliveries {
|
|
109
|
-
stateDir;
|
|
110
|
-
allowed;
|
|
111
|
-
deliveries = new Map();
|
|
112
|
-
cleanup = new Set();
|
|
113
|
-
constructor(stateDir, allowed) {
|
|
114
|
-
this.stateDir = stateDir;
|
|
115
|
-
this.allowed = allowed;
|
|
116
|
-
}
|
|
117
|
-
session(identity, expires) {
|
|
118
|
-
if (!identity.wake ||
|
|
119
|
-
this.stateDir === undefined ||
|
|
120
|
-
this.deliveries.has(identity.fingerprint256)) {
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
const mailbox = new Mailbox(this.stateDir, identity.fingerprint256, true);
|
|
124
|
-
const worker = new WakeDelivery(identity.name, mailbox, identity.wake, () => this.allowed(identity) && expires > Date.now());
|
|
125
|
-
this.deliveries.set(identity.fingerprint256, { identity, mailbox, worker });
|
|
126
|
-
worker.start();
|
|
127
|
-
}
|
|
128
223
|
drop(fp) {
|
|
129
224
|
const delivery = this.deliveries.get(fp);
|
|
130
|
-
if (!delivery) {
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
225
|
this.deliveries.delete(fp);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
log("wake_delivery", { name: delivery.identity.name, result: "cleanup_failed" });
|
|
139
|
-
})
|
|
140
|
-
.finally(() => {
|
|
141
|
-
this.cleanup.delete(cleanup);
|
|
142
|
-
});
|
|
143
|
-
this.cleanup.add(cleanup);
|
|
226
|
+
delivery?.controller.abort();
|
|
227
|
+
delivery?.mailbox.close();
|
|
228
|
+
// Membership removal alone is not an ACK. Reload restores proof-backed replacements first.
|
|
229
|
+
this.changed(false);
|
|
144
230
|
}
|
|
145
231
|
reload() {
|
|
146
232
|
for (const [fp, delivery] of this.deliveries) {
|
|
@@ -148,11 +234,16 @@ export class WakeDeliveries {
|
|
|
148
234
|
this.drop(fp);
|
|
149
235
|
}
|
|
150
236
|
}
|
|
237
|
+
this.changed(false);
|
|
151
238
|
}
|
|
152
239
|
async stop() {
|
|
153
|
-
|
|
154
|
-
|
|
240
|
+
this.controller.abort();
|
|
241
|
+
clearTimeout(this.timer);
|
|
242
|
+
await this.running;
|
|
243
|
+
for (const delivery of this.deliveries.values()) {
|
|
244
|
+
delivery.mailbox.close();
|
|
155
245
|
}
|
|
156
|
-
|
|
246
|
+
this.deliveries.clear();
|
|
247
|
+
this.state?.close();
|
|
157
248
|
}
|
|
158
249
|
}
|
|
@@ -7,12 +7,21 @@ import { loadConfig, sameListener } from "./bridge-config.js";
|
|
|
7
7
|
import { ASK_TIMEOUT, Fault, fingerprint, log } from "./bridge-protocol.js";
|
|
8
8
|
import { Session } from "./bridge-session.js";
|
|
9
9
|
import { operationFor, routeFor } from "./bridge-routes.js";
|
|
10
|
-
import { reply } from "./bridge-http.js";
|
|
11
|
-
import {
|
|
10
|
+
import { reply, replyFault } from "./bridge-http.js";
|
|
11
|
+
import { sameIdentity } from "./bridge-launch.js";
|
|
12
|
+
import { BridgeOperations } from "./bridge-operations.js";
|
|
13
|
+
import { authenticatedCertificateChain } from "./native-control-auth.js";
|
|
12
14
|
import { PersistentPresence } from "./bridge-presence.js";
|
|
13
15
|
import { WakeDeliveries } from "./bridge-wake.js";
|
|
14
16
|
import { spawnBrokerIfNeeded } from "./broker/spawn.js";
|
|
15
17
|
const MAX_CONNECTIONS = 128;
|
|
18
|
+
const LONG_ROUTES = new Set([
|
|
19
|
+
"POST /v1/start",
|
|
20
|
+
"POST /v1/spawn",
|
|
21
|
+
"POST /v1/resume",
|
|
22
|
+
"GET /v1/transcript",
|
|
23
|
+
"POST /v1/transcript",
|
|
24
|
+
]);
|
|
16
25
|
function validCertificateDates(starts, expires) {
|
|
17
26
|
return (Number.isFinite(starts) &&
|
|
18
27
|
Number.isFinite(expires) &&
|
|
@@ -24,7 +33,7 @@ class Bridge {
|
|
|
24
33
|
configPath;
|
|
25
34
|
sessions = new Map();
|
|
26
35
|
losses = new Map();
|
|
27
|
-
|
|
36
|
+
operations;
|
|
28
37
|
sockets = new Set();
|
|
29
38
|
connections = new Map();
|
|
30
39
|
server;
|
|
@@ -35,11 +44,13 @@ class Bridge {
|
|
|
35
44
|
constructor(config, configPath) {
|
|
36
45
|
this.config = config;
|
|
37
46
|
this.configPath = configPath;
|
|
47
|
+
this.operations = new BridgeOperations(config.stateDir);
|
|
38
48
|
this.deliveries = new WakeDeliveries(config.stateDir, (identity) => this.allowed(identity));
|
|
39
49
|
if (config.stateDir !== undefined) {
|
|
40
50
|
this.presence = new PersistentPresence({
|
|
41
51
|
stateDir: config.stateDir,
|
|
42
52
|
clients: () => this.config.clients,
|
|
53
|
+
restored: (identity, expires) => this.deliveries.session(identity, expires),
|
|
43
54
|
connect: async (identity, expires) => {
|
|
44
55
|
await spawnBrokerIfNeeded(process.execPath, []);
|
|
45
56
|
if (!this.allowed(identity)) {
|
|
@@ -122,6 +133,7 @@ class Bridge {
|
|
|
122
133
|
session = new Session({
|
|
123
134
|
stateDir: this.config.stateDir,
|
|
124
135
|
notifications: identity.wake !== undefined,
|
|
136
|
+
mailboxChanged: () => this.deliveries.changed(),
|
|
125
137
|
allowed: (candidate) => this.allowed(candidate),
|
|
126
138
|
lost: (fp, count) => this.lost(fp, count),
|
|
127
139
|
losses: (fp) => this.losses.get(fp) ?? 0,
|
|
@@ -221,13 +233,13 @@ class Bridge {
|
|
|
221
233
|
}
|
|
222
234
|
this.config = next;
|
|
223
235
|
this.revokeRemoved();
|
|
224
|
-
this.
|
|
236
|
+
this.operations.revoke(next.clients).catch(() => log("launch_cleanup", { result: "failed" }));
|
|
225
237
|
log("allowlist_reloaded", { result: "accepted", clients: next.clients.size });
|
|
226
238
|
}
|
|
227
239
|
catch {
|
|
228
240
|
this.config = { ...this.config, clients: new Map() };
|
|
229
241
|
this.deliveries.reload();
|
|
230
|
-
this.
|
|
242
|
+
this.operations
|
|
231
243
|
.revoke(this.config.clients)
|
|
232
244
|
.catch(() => log("launch_cleanup", { result: "failed" }));
|
|
233
245
|
for (const session of this.sessions.values()) {
|
|
@@ -257,6 +269,9 @@ class Bridge {
|
|
|
257
269
|
this.losses.delete(fp);
|
|
258
270
|
}
|
|
259
271
|
}
|
|
272
|
+
// Retire stale sessions before publishing replacement delivery membership for the same pin.
|
|
273
|
+
this.presence?.restoreAll();
|
|
274
|
+
this.deliveries.changed();
|
|
260
275
|
}
|
|
261
276
|
stop() {
|
|
262
277
|
this.shutdown().catch(() => {
|
|
@@ -280,7 +295,7 @@ class Bridge {
|
|
|
280
295
|
await Promise.all([
|
|
281
296
|
...this.handlers,
|
|
282
297
|
...sessions.map((session) => session.settled()),
|
|
283
|
-
this.
|
|
298
|
+
this.operations.shutdown(),
|
|
284
299
|
this.presence?.stop(),
|
|
285
300
|
this.deliveries.stop(),
|
|
286
301
|
]);
|
|
@@ -302,7 +317,11 @@ class Bridge {
|
|
|
302
317
|
this.drop(connection.fingerprint, fault);
|
|
303
318
|
throw fault;
|
|
304
319
|
}
|
|
305
|
-
return {
|
|
320
|
+
return {
|
|
321
|
+
identity,
|
|
322
|
+
expires: connection.expires,
|
|
323
|
+
certificate: authenticatedCertificateChain(socket),
|
|
324
|
+
};
|
|
306
325
|
}
|
|
307
326
|
async handle(req, res) {
|
|
308
327
|
const requestId = randomUUID();
|
|
@@ -311,7 +330,9 @@ class Bridge {
|
|
|
311
330
|
let controller;
|
|
312
331
|
let deadline;
|
|
313
332
|
let peer;
|
|
314
|
-
|
|
333
|
+
let readOnlyInbox = false;
|
|
334
|
+
const cancel = (fault) => readOnlyInbox ? controller?.abort(fault) : session?.close(fault);
|
|
335
|
+
const abort = () => cancel(new Fault(499, "client_aborted", "HTTP client disconnected; delivery may be unknown."));
|
|
315
336
|
const onClose = () => {
|
|
316
337
|
if (!res.writableFinished) {
|
|
317
338
|
abort();
|
|
@@ -319,26 +340,31 @@ class Bridge {
|
|
|
319
340
|
};
|
|
320
341
|
const setDeadline = (ms) => {
|
|
321
342
|
clearTimeout(deadline);
|
|
322
|
-
deadline = setTimeout(() =>
|
|
343
|
+
deadline = setTimeout(() => cancel(new Fault(504, "request_timeout", "HTTP operation deadline exceeded; delivery may be unknown.")), Math.max(1, ms - (Date.now() - started)));
|
|
323
344
|
};
|
|
324
345
|
try {
|
|
325
|
-
const { identity, expires } = this.admission(req);
|
|
346
|
+
const { identity, expires, certificate } = this.admission(req);
|
|
326
347
|
const route = routeFor(req);
|
|
348
|
+
readOnlyInbox = route === "GET /v1/inbox";
|
|
327
349
|
session = this.session(identity, expires);
|
|
328
350
|
controller = session.begin();
|
|
329
351
|
const signal = controller.signal;
|
|
330
352
|
req.once("aborted", abort);
|
|
331
353
|
res.once("close", onClose);
|
|
332
354
|
const duration = route === "POST /v1/ask" ? ASK_TIMEOUT + 15000 : 15000;
|
|
333
|
-
setDeadline(route
|
|
355
|
+
setDeadline(LONG_ROUTES.has(route) ? 75000 : duration);
|
|
334
356
|
const operation = await operationFor(req, route, signal);
|
|
335
357
|
if (operation.route === "POST /v1/ask") {
|
|
336
358
|
setDeadline(operation.timeoutMs + 15000);
|
|
337
359
|
}
|
|
338
|
-
|
|
360
|
+
else if (operation.route === "GET /v1/transcript") {
|
|
361
|
+
setDeadline(75000);
|
|
362
|
+
}
|
|
363
|
+
const data = await this.operations.execute(operation, session, {
|
|
339
364
|
signal,
|
|
340
365
|
requestId,
|
|
341
366
|
expires,
|
|
367
|
+
certificate,
|
|
342
368
|
onPeer: (id) => {
|
|
343
369
|
peer = id;
|
|
344
370
|
},
|
|
@@ -352,10 +378,7 @@ class Bridge {
|
|
|
352
378
|
? error
|
|
353
379
|
: new Fault(503, "broker_unavailable", "Local broker operation failed.");
|
|
354
380
|
log("request", { ...session?.fields(), requestId, peer, result: fault.code });
|
|
355
|
-
|
|
356
|
-
ok: false,
|
|
357
|
-
error: { code: fault.code, message: fault.message, ...fault.details },
|
|
358
|
-
});
|
|
381
|
+
replyFault(res, requestId, fault);
|
|
359
382
|
}
|
|
360
383
|
finally {
|
|
361
384
|
clearTimeout(deadline);
|
|
@@ -366,29 +389,6 @@ class Bridge {
|
|
|
366
389
|
}
|
|
367
390
|
}
|
|
368
391
|
}
|
|
369
|
-
async execute(operation, session, context) {
|
|
370
|
-
switch (operation.route) {
|
|
371
|
-
case "POST /v1/start":
|
|
372
|
-
return this.launcher.start(operation.name, {
|
|
373
|
-
identity: session.identity,
|
|
374
|
-
expires: context.expires,
|
|
375
|
-
signal: context.signal,
|
|
376
|
-
peers: (signal) => session.connectedPeers(signal),
|
|
377
|
-
});
|
|
378
|
-
case "GET /v1/sessions":
|
|
379
|
-
return this.launcher.list(session.identity);
|
|
380
|
-
case "POST /v1/stop":
|
|
381
|
-
return this.launcher.stop(session.identity, operation.sessionId);
|
|
382
|
-
case "GET /v1/list":
|
|
383
|
-
case "GET /v1/inbox":
|
|
384
|
-
case "POST /v1/register":
|
|
385
|
-
case "POST /v1/ask":
|
|
386
|
-
case "POST /v1/send":
|
|
387
|
-
case "POST /v1/reply":
|
|
388
|
-
case "POST /v1/ack":
|
|
389
|
-
return session.execute(operation, context);
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
392
|
}
|
|
393
393
|
function main() {
|
|
394
394
|
const args = process.argv.slice(2);
|
|
@@ -320,6 +320,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
320
320
|
expectsReply: options.expectsReply,
|
|
321
321
|
...(options.human ? { human: options.human } : {}),
|
|
322
322
|
...(options.topic ? { topic: options.topic } : {}),
|
|
323
|
+
...(options.control ? { control: options.control } : {}),
|
|
323
324
|
delivery: messageDelivery(options),
|
|
324
325
|
queueMode: options.queueMode,
|
|
325
326
|
threadId: options.threadId,
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
1
|
import { IntercomClient } from "./broker/client.js";
|
|
3
2
|
import { spawnBrokerIfNeeded } from "./broker/spawn.js";
|
|
4
3
|
import { asError, errorMessage } from "./validation.js";
|
|
5
|
-
import { formatTargetOptions, resolveSessionTarget } from "./session-targets.js";
|
|
4
|
+
import { formatTargetOptions, resolveSessionTarget, nativePeerId } from "./session-targets.js";
|
|
6
5
|
/** Owns socket registration, coalesced connects and the reconnect/startup timers. */
|
|
7
6
|
export class IntercomConnection {
|
|
8
7
|
client = null;
|
|
@@ -195,7 +194,7 @@ export class IntercomConnection {
|
|
|
195
194
|
if (reason !== "peer-awareness") {
|
|
196
195
|
await spawnBrokerIfNeeded(this.config.brokerCommand, this.config.brokerArgs);
|
|
197
196
|
}
|
|
198
|
-
await next.connect(await this.lifecycle.registration(this.hooks.pendingAsks()),
|
|
197
|
+
await next.connect(await this.lifecycle.registration(this.hooks.pendingAsks()), nativePeerId(sessionId));
|
|
199
198
|
if (!this.lifecycle.live(ctx, generation)) {
|
|
200
199
|
await next.disconnect();
|
|
201
200
|
throw new Error("Intercom runtime no longer active");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
1
|
import { requestedDelivery, isBlockingSupervisorMessage } from "./inbound-record.js";
|
|
3
2
|
import { formatAttachments } from "./message-format.js";
|
|
4
3
|
import { readQuestionOwner } from "../runs/shared/supervisor-questions.js";
|
|
4
|
+
import { nativePeerId } from "./session-targets.js";
|
|
5
5
|
/** Validates origin and chooses admission; state/resources remain in their owners. */
|
|
6
6
|
export class IntercomInbound {
|
|
7
7
|
lifecycle;
|
|
@@ -19,9 +19,7 @@ export class IntercomInbound {
|
|
|
19
19
|
return false;
|
|
20
20
|
}
|
|
21
21
|
const owner = readQuestionOwner(origin.runId);
|
|
22
|
-
return (owner?.sessionId === origin.ownerSessionId &&
|
|
23
|
-
from.id ===
|
|
24
|
-
`pi-${createHash("sha256").update(origin.ownerSessionId).digest("hex").slice(0, 32)}`);
|
|
22
|
+
return (owner?.sessionId === origin.ownerSessionId && from.id === nativePeerId(origin.ownerSessionId));
|
|
25
23
|
}
|
|
26
24
|
normalize(from, input) {
|
|
27
25
|
const message = input.topic
|
|
@@ -8,6 +8,7 @@ import { InboundDeliveryOwner } from "./inbound-delivery.js";
|
|
|
8
8
|
import { ReplyWait } from "./reply-wait.js";
|
|
9
9
|
import { IntercomConnection } from "./connection.js";
|
|
10
10
|
import { IntercomInbound } from "./inbound.js";
|
|
11
|
+
import { NativeControls } from "./native-control.js";
|
|
11
12
|
import { IntercomOutbound } from "./outbound.js";
|
|
12
13
|
import { IntercomInspection } from "./inspection.js";
|
|
13
14
|
import { IntercomTopicActions } from "./topic-actions.js";
|
|
@@ -31,7 +32,11 @@ export default function piIntercomExtension(pi) {
|
|
|
31
32
|
const connection = new IntercomConnection(lifecycle, config, topics, {
|
|
32
33
|
restoring: () => delivery.restoring,
|
|
33
34
|
pendingAsks: () => replies.listPending().length,
|
|
34
|
-
incoming: (ctx, from, message) =>
|
|
35
|
+
incoming: async (ctx, from, message) => {
|
|
36
|
+
if (!(await controls.receive(ctx, from, message))) {
|
|
37
|
+
await inbound.receive(ctx, from, message);
|
|
38
|
+
}
|
|
39
|
+
},
|
|
35
40
|
peerLeft: (id) => {
|
|
36
41
|
wait.peerDisconnected(id);
|
|
37
42
|
journal.peerLeft(id);
|
|
@@ -42,6 +47,7 @@ export default function piIntercomExtension(pi) {
|
|
|
42
47
|
}
|
|
43
48
|
},
|
|
44
49
|
});
|
|
50
|
+
const controls = new NativeControls(lifecycle, connection);
|
|
45
51
|
const delivery = new InboundDeliveryOwner(pi, lifecycle, journal, {
|
|
46
52
|
replies,
|
|
47
53
|
syncStatus: () => connection.syncStatus(),
|