@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,174 +1,263 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { log } from "./bridge-protocol.ts";
|
|
2
3
|
import { Mailbox, type PendingNotification } from "./bridge-mailbox.ts";
|
|
3
|
-
import type { Identity
|
|
4
|
+
import type { Identity } from "./bridge-config.ts";
|
|
5
|
+
import { WakeState } from "./bridge-wake-state.ts";
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
interface Delivery {
|
|
8
|
+
readonly identity: Identity;
|
|
9
|
+
readonly mailbox: Mailbox;
|
|
10
|
+
readonly expires: number;
|
|
11
|
+
readonly controller: AbortController;
|
|
12
|
+
}
|
|
13
|
+
interface Candidate {
|
|
14
|
+
readonly delivery: Delivery;
|
|
15
|
+
readonly entry: PendingNotification;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function actionable(entry: PendingNotification): boolean {
|
|
19
|
+
const message = entry.message;
|
|
20
|
+
if (entry.acknowledged || message.passive === true || message.delivery === "passive") {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
return (
|
|
24
|
+
message.expectsReply === true ||
|
|
25
|
+
!/^(?:ACK|DUP-ACK|FYI|NOTE|RECEIPT|NOWAKE)(?=$|[\s:])/.test(message.content.text)
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function bucket(identity: Identity): string {
|
|
30
|
+
return createHash("sha256")
|
|
31
|
+
.update(identity.wake?.url ?? "")
|
|
32
|
+
.digest("hex");
|
|
33
|
+
}
|
|
8
34
|
|
|
9
|
-
|
|
35
|
+
/** Mailbox commits and durable retry/quota deadlines drive wakes; there is no intake scan timer. */
|
|
36
|
+
export class WakeDeliveries {
|
|
37
|
+
private readonly stateDir: string | undefined;
|
|
38
|
+
private readonly state?: WakeState;
|
|
39
|
+
private readonly allowed: (identity: Identity) => boolean;
|
|
40
|
+
private readonly deliveries = new Map<string, Delivery>();
|
|
10
41
|
private readonly controller = new AbortController();
|
|
11
42
|
private timer?: NodeJS.Timeout;
|
|
12
43
|
private running?: Promise<void>;
|
|
13
|
-
private
|
|
14
|
-
private
|
|
15
|
-
private readonly mailbox: Pick<Mailbox, "pendingNotifications" | "notificationAccepted">;
|
|
16
|
-
private readonly policy: WakePolicy;
|
|
17
|
-
private readonly authorized: () => boolean;
|
|
18
|
-
|
|
19
|
-
constructor(
|
|
20
|
-
name: string,
|
|
21
|
-
mailbox: Pick<Mailbox, "pendingNotifications" | "notificationAccepted">,
|
|
22
|
-
policy: WakePolicy,
|
|
23
|
-
authorized: () => boolean,
|
|
24
|
-
) {
|
|
25
|
-
this.name = name;
|
|
26
|
-
this.mailbox = mailbox;
|
|
27
|
-
this.policy = policy;
|
|
28
|
-
this.authorized = authorized;
|
|
29
|
-
}
|
|
44
|
+
private dirty = false;
|
|
45
|
+
private storageBackoff = 15000;
|
|
30
46
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
47
|
+
constructor(stateDir: string | undefined, allowed: (identity: Identity) => boolean) {
|
|
48
|
+
this.stateDir = stateDir;
|
|
49
|
+
this.allowed = allowed;
|
|
50
|
+
// Initialize before any presence proof/mailbox is created, so genuinely fresh installs start now.
|
|
51
|
+
this.state = stateDir === undefined ? undefined : new WakeState(stateDir);
|
|
35
52
|
}
|
|
36
53
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
this.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
54
|
+
session(identity: Identity, expires: number): void {
|
|
55
|
+
if (
|
|
56
|
+
!identity.wake ||
|
|
57
|
+
this.stateDir === undefined ||
|
|
58
|
+
this.deliveries.has(identity.fingerprint256)
|
|
59
|
+
) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
this.deliveries.set(identity.fingerprint256, {
|
|
63
|
+
identity,
|
|
64
|
+
expires,
|
|
65
|
+
mailbox: new Mailbox(this.stateDir, identity.fingerprint256, true),
|
|
66
|
+
controller: new AbortController(),
|
|
67
|
+
});
|
|
68
|
+
// Startup restores every certificate proof synchronously before the scheduled reconciliation.
|
|
69
|
+
// An empty first fingerprint must not clear another retained fingerprint's durable cold batch.
|
|
70
|
+
this.changed(false);
|
|
52
71
|
}
|
|
53
72
|
|
|
54
|
-
|
|
55
|
-
if (
|
|
73
|
+
changed(reconcile = true): void {
|
|
74
|
+
if (this.controller.signal.aborted) {
|
|
56
75
|
return;
|
|
57
76
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
this.
|
|
77
|
+
try {
|
|
78
|
+
// ACK may be followed by fresh mail before the event timer runs. Clear completed batches now
|
|
79
|
+
// so a new obligation does not inherit a completed batch's uncertainty/backoff.
|
|
80
|
+
if (reconcile) {
|
|
81
|
+
this.reconcile();
|
|
63
82
|
}
|
|
83
|
+
} catch {
|
|
84
|
+
// The scheduled storage recovery fails closed; never let an event bypass durable admission.
|
|
64
85
|
}
|
|
65
|
-
|
|
66
|
-
|
|
86
|
+
if (this.running) {
|
|
87
|
+
this.dirty = true;
|
|
88
|
+
} else {
|
|
89
|
+
this.schedule(Date.now());
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private reconcile(): void {
|
|
94
|
+
for (const [key, candidate] of this.groups()) {
|
|
95
|
+
if (candidate) {
|
|
96
|
+
this.state?.deadline(key);
|
|
97
|
+
} else {
|
|
98
|
+
this.state?.retire(key);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private schedule(deadline: number): void {
|
|
104
|
+
clearTimeout(this.timer);
|
|
105
|
+
this.timer = setTimeout(
|
|
106
|
+
() => {
|
|
107
|
+
this.dirty = false;
|
|
108
|
+
let next = Infinity;
|
|
109
|
+
this.running = this.deliver()
|
|
110
|
+
.then((due) => {
|
|
111
|
+
next = due;
|
|
112
|
+
this.storageBackoff = 15000;
|
|
113
|
+
return;
|
|
114
|
+
})
|
|
115
|
+
.catch(() => {
|
|
116
|
+
log("wake_delivery", { result: "storage_unavailable" });
|
|
117
|
+
next = Date.now() + this.storageBackoff;
|
|
118
|
+
this.storageBackoff = Math.min(this.storageBackoff * 2, 300000);
|
|
119
|
+
})
|
|
120
|
+
.finally(() => {
|
|
121
|
+
this.running = undefined;
|
|
122
|
+
if (!this.controller.signal.aborted && (this.dirty || Number.isFinite(next))) {
|
|
123
|
+
this.schedule(this.dirty ? Date.now() : next);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
Math.max(0, Math.min(deadline - Date.now(), 0x7fffffff)),
|
|
67
128
|
);
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private authorized(delivery: Delivery): boolean {
|
|
132
|
+
return (
|
|
133
|
+
!this.controller.signal.aborted &&
|
|
134
|
+
this.allowed(delivery.identity) &&
|
|
135
|
+
delivery.expires > Date.now()
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private pending(delivery: Delivery): PendingNotification | undefined {
|
|
140
|
+
let after = 0;
|
|
141
|
+
let next: PendingNotification | undefined;
|
|
142
|
+
let page;
|
|
143
|
+
do {
|
|
144
|
+
page = delivery.mailbox.pendingNotifications(after);
|
|
145
|
+
for (const entry of page.messages) {
|
|
146
|
+
if (!actionable(entry)) {
|
|
147
|
+
// Retire only notification intent. Payload/reply obligations require actual ACK/reply.
|
|
148
|
+
delivery.mailbox.notificationAccepted(entry.message.id);
|
|
149
|
+
} else if (!next) {
|
|
150
|
+
next = entry;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
after = Number(page.nextCursor);
|
|
154
|
+
} while (page.hasMore);
|
|
155
|
+
return next;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private groups(): Map<string, Candidate | undefined> {
|
|
159
|
+
const groups = new Map<string, Candidate | undefined>();
|
|
160
|
+
for (const delivery of this.deliveries.values()) {
|
|
161
|
+
if (!this.authorized(delivery)) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const key = bucket(delivery.identity);
|
|
165
|
+
const entry = this.pending(delivery);
|
|
166
|
+
const previous = groups.get(key);
|
|
167
|
+
if (
|
|
168
|
+
!groups.has(key) ||
|
|
169
|
+
(entry && (!previous || entry.receivedAt < previous.entry.receivedAt))
|
|
170
|
+
) {
|
|
171
|
+
groups.set(key, entry ? { delivery, entry } : undefined);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return groups;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private async deliver(): Promise<number> {
|
|
178
|
+
const state = this.state;
|
|
179
|
+
let next = Infinity;
|
|
180
|
+
if (!state) {
|
|
181
|
+
return next;
|
|
182
|
+
}
|
|
183
|
+
// ACK can remove the final candidate while its failed completion write still needs recovery.
|
|
184
|
+
state.retrySettlement();
|
|
185
|
+
for (const [key, candidate] of this.groups()) {
|
|
186
|
+
if (!candidate) {
|
|
187
|
+
state.retire(key);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const due = state.deadline(key);
|
|
191
|
+
if (due > Date.now()) {
|
|
192
|
+
next = Math.min(next, due);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const deferred = state.reserve(key);
|
|
196
|
+
if (deferred !== undefined) {
|
|
197
|
+
next = Math.min(next, deferred);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
// ponytail: two cold Macs can each admit one wake. Literal cross-Mac single admission
|
|
201
|
+
// requires a neutral quota authority governing every writer, not a URL hash or HTTP 2xx.
|
|
202
|
+
try {
|
|
203
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
204
|
+
await this.post(candidate);
|
|
205
|
+
} finally {
|
|
206
|
+
// Expiry starts after response/error/abort, never before COMMIT or connect/TLS.
|
|
207
|
+
state.settle();
|
|
208
|
+
}
|
|
209
|
+
// Re-read ACKs and all groups after transport I/O; a snapshot must not wake already consumed mail.
|
|
210
|
+
return Date.now();
|
|
72
211
|
}
|
|
73
|
-
|
|
212
|
+
return next;
|
|
74
213
|
}
|
|
75
214
|
|
|
76
|
-
private async post(entry:
|
|
77
|
-
|
|
215
|
+
private async post({ delivery, entry }: Candidate): Promise<void> {
|
|
216
|
+
const policy = delivery.identity.wake;
|
|
217
|
+
if (!policy || !this.authorized(delivery)) {
|
|
78
218
|
return;
|
|
79
219
|
}
|
|
80
|
-
const fields = { name:
|
|
220
|
+
const fields = { name: delivery.identity.name, messageId: entry.message.id };
|
|
81
221
|
try {
|
|
82
|
-
const response = await fetch(
|
|
222
|
+
const response = await fetch(policy.url, {
|
|
83
223
|
method: "POST",
|
|
84
224
|
redirect: "error",
|
|
85
|
-
headers: {
|
|
86
|
-
Authorization: `Bearer ${this.policy.key}`,
|
|
87
|
-
"Content-Type": "application/json",
|
|
88
|
-
},
|
|
225
|
+
headers: { Authorization: `Bearer ${policy.key}`, "Content-Type": "application/json" },
|
|
89
226
|
body: JSON.stringify({
|
|
90
227
|
from: entry.from.name ?? entry.from.id,
|
|
91
228
|
from_id: entry.from.id,
|
|
92
|
-
to:
|
|
229
|
+
to: delivery.identity.name,
|
|
93
230
|
message_id: entry.message.id,
|
|
94
231
|
ask_id: entry.message.expectsReply === true ? entry.message.id : null,
|
|
95
232
|
text: entry.message.content.text,
|
|
96
233
|
}),
|
|
97
|
-
signal: AbortSignal.any([
|
|
234
|
+
signal: AbortSignal.any([
|
|
235
|
+
this.controller.signal,
|
|
236
|
+
delivery.controller.signal,
|
|
237
|
+
AbortSignal.timeout(10000),
|
|
238
|
+
]),
|
|
98
239
|
});
|
|
99
240
|
await response.body?.cancel();
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
log("wake_delivery", { ...fields, result: "http_rejected", status: response.status });
|
|
107
|
-
}
|
|
241
|
+
// Acceptance does not consume mail or reset uncertainty: only real inbox ACK retires intent.
|
|
242
|
+
log("wake_delivery", {
|
|
243
|
+
...fields,
|
|
244
|
+
result: response.ok ? "accepted" : "http_rejected",
|
|
245
|
+
status: response.status,
|
|
246
|
+
});
|
|
108
247
|
} catch {
|
|
109
|
-
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
|
|
110
248
|
if (!this.controller.signal.aborted) {
|
|
111
249
|
log("wake_delivery", { ...fields, result: "transport_unconfirmed" });
|
|
112
250
|
}
|
|
113
251
|
}
|
|
114
252
|
}
|
|
115
253
|
|
|
116
|
-
async stop(): Promise<void> {
|
|
117
|
-
this.controller.abort();
|
|
118
|
-
clearTimeout(this.timer);
|
|
119
|
-
await this.running;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export class WakeDeliveries {
|
|
124
|
-
private readonly stateDir: string | undefined;
|
|
125
|
-
private readonly allowed: (identity: Identity) => boolean;
|
|
126
|
-
private readonly deliveries = new Map<
|
|
127
|
-
string,
|
|
128
|
-
{ readonly identity: Identity; readonly mailbox: Mailbox; readonly worker: WakeDelivery }
|
|
129
|
-
>();
|
|
130
|
-
private readonly cleanup = new Set<Promise<unknown>>();
|
|
131
|
-
|
|
132
|
-
constructor(stateDir: string | undefined, allowed: (identity: Identity) => boolean) {
|
|
133
|
-
this.stateDir = stateDir;
|
|
134
|
-
this.allowed = allowed;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
session(identity: Identity, expires: number): void {
|
|
138
|
-
if (
|
|
139
|
-
!identity.wake ||
|
|
140
|
-
this.stateDir === undefined ||
|
|
141
|
-
this.deliveries.has(identity.fingerprint256)
|
|
142
|
-
) {
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
const mailbox = new Mailbox(this.stateDir, identity.fingerprint256, true);
|
|
146
|
-
const worker = new WakeDelivery(
|
|
147
|
-
identity.name,
|
|
148
|
-
mailbox,
|
|
149
|
-
identity.wake,
|
|
150
|
-
() => this.allowed(identity) && expires > Date.now(),
|
|
151
|
-
);
|
|
152
|
-
this.deliveries.set(identity.fingerprint256, { identity, mailbox, worker });
|
|
153
|
-
worker.start();
|
|
154
|
-
}
|
|
155
|
-
|
|
156
254
|
drop(fp: string): void {
|
|
157
255
|
const delivery = this.deliveries.get(fp);
|
|
158
|
-
if (!delivery) {
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
256
|
this.deliveries.delete(fp);
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
log("wake_delivery", { name: delivery.identity.name, result: "cleanup_failed" });
|
|
167
|
-
})
|
|
168
|
-
.finally(() => {
|
|
169
|
-
this.cleanup.delete(cleanup);
|
|
170
|
-
});
|
|
171
|
-
this.cleanup.add(cleanup);
|
|
257
|
+
delivery?.controller.abort();
|
|
258
|
+
delivery?.mailbox.close();
|
|
259
|
+
// Membership removal alone is not an ACK. Reload restores proof-backed replacements first.
|
|
260
|
+
this.changed(false);
|
|
172
261
|
}
|
|
173
262
|
|
|
174
263
|
reload(): void {
|
|
@@ -177,12 +266,17 @@ export class WakeDeliveries {
|
|
|
177
266
|
this.drop(fp);
|
|
178
267
|
}
|
|
179
268
|
}
|
|
269
|
+
this.changed(false);
|
|
180
270
|
}
|
|
181
271
|
|
|
182
272
|
async stop(): Promise<void> {
|
|
183
|
-
|
|
184
|
-
|
|
273
|
+
this.controller.abort();
|
|
274
|
+
clearTimeout(this.timer);
|
|
275
|
+
await this.running;
|
|
276
|
+
for (const delivery of this.deliveries.values()) {
|
|
277
|
+
delivery.mailbox.close();
|
|
185
278
|
}
|
|
186
|
-
|
|
279
|
+
this.deliveries.clear();
|
|
280
|
+
this.state?.close();
|
|
187
281
|
}
|
|
188
282
|
}
|
|
@@ -8,14 +8,23 @@ import { getBrokerSocketPath } from "./broker/paths.ts";
|
|
|
8
8
|
import { loadConfig, sameListener, type Config, type Identity } from "./bridge-config.ts";
|
|
9
9
|
import { ASK_TIMEOUT, Fault, fingerprint, log } from "./bridge-protocol.ts";
|
|
10
10
|
import { Session } from "./bridge-session.ts";
|
|
11
|
-
import { operationFor, routeFor
|
|
12
|
-
import { reply } from "./bridge-http.ts";
|
|
13
|
-
import {
|
|
11
|
+
import { operationFor, routeFor } from "./bridge-routes.ts";
|
|
12
|
+
import { reply, replyFault } from "./bridge-http.ts";
|
|
13
|
+
import { sameIdentity } from "./bridge-launch.ts";
|
|
14
|
+
import { BridgeOperations } from "./bridge-operations.ts";
|
|
15
|
+
import { authenticatedCertificateChain } from "./native-control-auth.ts";
|
|
14
16
|
import { PersistentPresence } from "./bridge-presence.ts";
|
|
15
17
|
import { WakeDeliveries } from "./bridge-wake.ts";
|
|
16
18
|
import { spawnBrokerIfNeeded } from "./broker/spawn.ts";
|
|
17
19
|
|
|
18
20
|
const MAX_CONNECTIONS = 128;
|
|
21
|
+
const LONG_ROUTES = new Set([
|
|
22
|
+
"POST /v1/start",
|
|
23
|
+
"POST /v1/spawn",
|
|
24
|
+
"POST /v1/resume",
|
|
25
|
+
"GET /v1/transcript",
|
|
26
|
+
"POST /v1/transcript",
|
|
27
|
+
]);
|
|
19
28
|
|
|
20
29
|
function validCertificateDates(starts: number, expires: number): boolean {
|
|
21
30
|
return (
|
|
@@ -37,7 +46,7 @@ class Bridge {
|
|
|
37
46
|
private readonly configPath: string;
|
|
38
47
|
private readonly sessions = new Map<string, Session>();
|
|
39
48
|
private readonly losses = new Map<string, number>();
|
|
40
|
-
private readonly
|
|
49
|
+
private readonly operations: BridgeOperations;
|
|
41
50
|
private readonly sockets = new Set<Socket>();
|
|
42
51
|
private readonly connections = new Map<TLSSocket, Connection>();
|
|
43
52
|
private readonly server: Server;
|
|
@@ -49,11 +58,13 @@ class Bridge {
|
|
|
49
58
|
constructor(config: Config, configPath: string) {
|
|
50
59
|
this.config = config;
|
|
51
60
|
this.configPath = configPath;
|
|
61
|
+
this.operations = new BridgeOperations(config.stateDir);
|
|
52
62
|
this.deliveries = new WakeDeliveries(config.stateDir, (identity) => this.allowed(identity));
|
|
53
63
|
if (config.stateDir !== undefined) {
|
|
54
64
|
this.presence = new PersistentPresence({
|
|
55
65
|
stateDir: config.stateDir,
|
|
56
66
|
clients: () => this.config.clients,
|
|
67
|
+
restored: (identity, expires) => this.deliveries.session(identity, expires),
|
|
57
68
|
connect: async (identity, expires) => {
|
|
58
69
|
await spawnBrokerIfNeeded(process.execPath, []);
|
|
59
70
|
if (!this.allowed(identity)) {
|
|
@@ -143,6 +154,7 @@ class Bridge {
|
|
|
143
154
|
{
|
|
144
155
|
stateDir: this.config.stateDir,
|
|
145
156
|
notifications: identity.wake !== undefined,
|
|
157
|
+
mailboxChanged: () => this.deliveries.changed(),
|
|
146
158
|
allowed: (candidate) => this.allowed(candidate),
|
|
147
159
|
lost: (fp, count) => this.lost(fp, count),
|
|
148
160
|
losses: (fp) => this.losses.get(fp) ?? 0,
|
|
@@ -245,12 +257,12 @@ class Bridge {
|
|
|
245
257
|
}
|
|
246
258
|
this.config = next;
|
|
247
259
|
this.revokeRemoved();
|
|
248
|
-
this.
|
|
260
|
+
this.operations.revoke(next.clients).catch(() => log("launch_cleanup", { result: "failed" }));
|
|
249
261
|
log("allowlist_reloaded", { result: "accepted", clients: next.clients.size });
|
|
250
262
|
} catch {
|
|
251
263
|
this.config = { ...this.config, clients: new Map() };
|
|
252
264
|
this.deliveries.reload();
|
|
253
|
-
this.
|
|
265
|
+
this.operations
|
|
254
266
|
.revoke(this.config.clients)
|
|
255
267
|
.catch(() => log("launch_cleanup", { result: "failed" }));
|
|
256
268
|
for (const session of this.sessions.values()) {
|
|
@@ -289,6 +301,9 @@ class Bridge {
|
|
|
289
301
|
this.losses.delete(fp);
|
|
290
302
|
}
|
|
291
303
|
}
|
|
304
|
+
// Retire stale sessions before publishing replacement delivery membership for the same pin.
|
|
305
|
+
this.presence?.restoreAll();
|
|
306
|
+
this.deliveries.changed();
|
|
292
307
|
}
|
|
293
308
|
private stop(): void {
|
|
294
309
|
this.shutdown().catch(() => {
|
|
@@ -312,7 +327,7 @@ class Bridge {
|
|
|
312
327
|
await Promise.all([
|
|
313
328
|
...this.handlers,
|
|
314
329
|
...sessions.map((session) => session.settled()),
|
|
315
|
-
this.
|
|
330
|
+
this.operations.shutdown(),
|
|
316
331
|
this.presence?.stop(),
|
|
317
332
|
this.deliveries.stop(),
|
|
318
333
|
]);
|
|
@@ -321,6 +336,7 @@ class Bridge {
|
|
|
321
336
|
private admission(req: IncomingMessage): {
|
|
322
337
|
readonly identity: Identity;
|
|
323
338
|
readonly expires: number;
|
|
339
|
+
readonly certificate: string;
|
|
324
340
|
} {
|
|
325
341
|
const socket = req.socket;
|
|
326
342
|
const connection = socket instanceof TLSSocket ? this.connections.get(socket) : undefined;
|
|
@@ -339,7 +355,11 @@ class Bridge {
|
|
|
339
355
|
this.drop(connection.fingerprint, fault);
|
|
340
356
|
throw fault;
|
|
341
357
|
}
|
|
342
|
-
return {
|
|
358
|
+
return {
|
|
359
|
+
identity,
|
|
360
|
+
expires: connection.expires,
|
|
361
|
+
certificate: authenticatedCertificateChain(socket),
|
|
362
|
+
};
|
|
343
363
|
}
|
|
344
364
|
private async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
345
365
|
const requestId = randomUUID();
|
|
@@ -348,8 +368,11 @@ class Bridge {
|
|
|
348
368
|
let controller: AbortController | undefined;
|
|
349
369
|
let deadline: NodeJS.Timeout | undefined;
|
|
350
370
|
let peer: string | undefined;
|
|
371
|
+
let readOnlyInbox = false;
|
|
372
|
+
const cancel = (fault: Readonly<Fault>) =>
|
|
373
|
+
readOnlyInbox ? controller?.abort(fault) : session?.close(fault);
|
|
351
374
|
const abort = () =>
|
|
352
|
-
|
|
375
|
+
cancel(
|
|
353
376
|
new Fault(499, "client_aborted", "HTTP client disconnected; delivery may be unknown."),
|
|
354
377
|
);
|
|
355
378
|
const onClose = () => {
|
|
@@ -361,7 +384,7 @@ class Bridge {
|
|
|
361
384
|
clearTimeout(deadline);
|
|
362
385
|
deadline = setTimeout(
|
|
363
386
|
() =>
|
|
364
|
-
|
|
387
|
+
cancel(
|
|
365
388
|
new Fault(
|
|
366
389
|
504,
|
|
367
390
|
"request_timeout",
|
|
@@ -372,23 +395,27 @@ class Bridge {
|
|
|
372
395
|
);
|
|
373
396
|
};
|
|
374
397
|
try {
|
|
375
|
-
const { identity, expires } = this.admission(req);
|
|
398
|
+
const { identity, expires, certificate } = this.admission(req);
|
|
376
399
|
const route = routeFor(req);
|
|
400
|
+
readOnlyInbox = route === "GET /v1/inbox";
|
|
377
401
|
session = this.session(identity, expires);
|
|
378
402
|
controller = session.begin();
|
|
379
403
|
const signal = controller.signal;
|
|
380
404
|
req.once("aborted", abort);
|
|
381
405
|
res.once("close", onClose);
|
|
382
406
|
const duration = route === "POST /v1/ask" ? ASK_TIMEOUT + 15000 : 15000;
|
|
383
|
-
setDeadline(route
|
|
407
|
+
setDeadline(LONG_ROUTES.has(route) ? 75000 : duration);
|
|
384
408
|
const operation = await operationFor(req, route, signal);
|
|
385
409
|
if (operation.route === "POST /v1/ask") {
|
|
386
410
|
setDeadline(operation.timeoutMs + 15000);
|
|
411
|
+
} else if (operation.route === "GET /v1/transcript") {
|
|
412
|
+
setDeadline(75000);
|
|
387
413
|
}
|
|
388
|
-
const data = await this.execute(operation, session, {
|
|
414
|
+
const data = await this.operations.execute(operation, session, {
|
|
389
415
|
signal,
|
|
390
416
|
requestId,
|
|
391
417
|
expires,
|
|
418
|
+
certificate,
|
|
392
419
|
onPeer: (id) => {
|
|
393
420
|
peer = id;
|
|
394
421
|
},
|
|
@@ -402,10 +429,7 @@ class Bridge {
|
|
|
402
429
|
? error
|
|
403
430
|
: new Fault(503, "broker_unavailable", "Local broker operation failed.");
|
|
404
431
|
log("request", { ...session?.fields(), requestId, peer, result: fault.code });
|
|
405
|
-
|
|
406
|
-
ok: false,
|
|
407
|
-
error: { code: fault.code, message: fault.message, ...fault.details },
|
|
408
|
-
});
|
|
432
|
+
replyFault(res, requestId, fault);
|
|
409
433
|
} finally {
|
|
410
434
|
clearTimeout(deadline);
|
|
411
435
|
req.off("aborted", abort);
|
|
@@ -415,38 +439,6 @@ class Bridge {
|
|
|
415
439
|
}
|
|
416
440
|
}
|
|
417
441
|
}
|
|
418
|
-
private async execute(
|
|
419
|
-
operation: Operation,
|
|
420
|
-
session: Readonly<Session>,
|
|
421
|
-
context: {
|
|
422
|
-
readonly signal: AbortSignal;
|
|
423
|
-
readonly requestId: string;
|
|
424
|
-
readonly expires: number;
|
|
425
|
-
readonly onPeer: (id: string) => void;
|
|
426
|
-
},
|
|
427
|
-
) {
|
|
428
|
-
switch (operation.route) {
|
|
429
|
-
case "POST /v1/start":
|
|
430
|
-
return this.launcher.start(operation.name, {
|
|
431
|
-
identity: session.identity,
|
|
432
|
-
expires: context.expires,
|
|
433
|
-
signal: context.signal,
|
|
434
|
-
peers: (signal) => session.connectedPeers(signal),
|
|
435
|
-
});
|
|
436
|
-
case "GET /v1/sessions":
|
|
437
|
-
return this.launcher.list(session.identity);
|
|
438
|
-
case "POST /v1/stop":
|
|
439
|
-
return this.launcher.stop(session.identity, operation.sessionId);
|
|
440
|
-
case "GET /v1/list":
|
|
441
|
-
case "GET /v1/inbox":
|
|
442
|
-
case "POST /v1/register":
|
|
443
|
-
case "POST /v1/ask":
|
|
444
|
-
case "POST /v1/send":
|
|
445
|
-
case "POST /v1/reply":
|
|
446
|
-
case "POST /v1/ack":
|
|
447
|
-
return session.execute(operation, context);
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
442
|
}
|
|
451
443
|
|
|
452
444
|
function main(): void {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { EventEmitter } from "events";
|
|
2
2
|
import type net from "net";
|
|
3
3
|
import { randomUUID } from "crypto";
|
|
4
|
+
import type { NativeControlEnvelope } from "../native-control-protocol.ts";
|
|
4
5
|
import { writeMessage } from "./framing.ts";
|
|
5
6
|
import { BrokerConnection, connectBrokerSocket } from "./connection.ts";
|
|
6
7
|
import { SnapshotReader } from "./snapshot-reader.ts";
|
|
@@ -51,6 +52,7 @@ interface SendOptions {
|
|
|
51
52
|
readonly messageId?: string;
|
|
52
53
|
readonly human?: HumanMessageOrigin;
|
|
53
54
|
readonly topic?: TopicUpdate;
|
|
55
|
+
readonly control?: NativeControlEnvelope;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
function toError(error: unknown): Error {
|
|
@@ -405,6 +407,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
405
407
|
expectsReply: options.expectsReply,
|
|
406
408
|
...(options.human ? { human: options.human } : {}),
|
|
407
409
|
...(options.topic ? { topic: options.topic } : {}),
|
|
410
|
+
...(options.control ? { control: options.control } : {}),
|
|
408
411
|
delivery: messageDelivery(options),
|
|
409
412
|
queueMode: options.queueMode,
|
|
410
413
|
threadId: options.threadId,
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
2
|
import { IntercomClient } from "./broker/client.ts";
|
|
4
3
|
import type { IntercomTransport } from "./transport.ts";
|
|
@@ -8,7 +7,7 @@ import type { IntercomLifecycle } from "./lifecycle.ts";
|
|
|
8
7
|
import type { IntercomTopics } from "./topics.ts";
|
|
9
8
|
import type { Message, SessionInfo, TopicChange } from "./types.ts";
|
|
10
9
|
import { asError, errorMessage } from "./validation.ts";
|
|
11
|
-
import { formatTargetOptions, resolveSessionTarget } from "./session-targets.ts";
|
|
10
|
+
import { formatTargetOptions, resolveSessionTarget, nativePeerId } from "./session-targets.ts";
|
|
12
11
|
|
|
13
12
|
export type ConnectionReason = "startup" | "background" | "tool" | "overlay" | "peer-awareness";
|
|
14
13
|
type Lifecycle = Readonly<
|
|
@@ -233,7 +232,7 @@ export class IntercomConnection {
|
|
|
233
232
|
}
|
|
234
233
|
await next.connect(
|
|
235
234
|
await this.lifecycle.registration(this.hooks.pendingAsks()),
|
|
236
|
-
|
|
235
|
+
nativePeerId(sessionId),
|
|
237
236
|
);
|
|
238
237
|
if (!this.lifecycle.live(ctx, generation)) {
|
|
239
238
|
await next.disconnect();
|