@fitchmultz/pi-subagents 0.48.0 → 0.49.1
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 +20 -0
- package/agents/worker.md +2 -2
- package/dist/extension/build-info.js +1 -1
- package/dist/pi-intercom/bridge-config.js +1 -1
- package/dist/pi-intercom/bridge-mailbox.js +3 -55
- package/dist/pi-intercom/bridge-presence.js +5 -1
- package/dist/pi-intercom/bridge-routes.js +25 -6
- package/dist/pi-intercom/bridge-session.js +60 -45
- package/dist/pi-intercom/bridge-storage.js +73 -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 +10 -2
- package/docs/intercom-bridge.md +56 -25
- package/package.json +1 -1
- package/scripts/intercom-bridge-wake-preflight.mjs +112 -0
- package/scripts/intercom-remote.mjs +39 -5
- package/src/pi-intercom/bridge-config.ts +1 -1
- package/src/pi-intercom/bridge-mailbox.ts +3 -59
- package/src/pi-intercom/bridge-presence.ts +7 -1
- package/src/pi-intercom/bridge-routes.ts +38 -8
- package/src/pi-intercom/bridge-session.ts +64 -61
- package/src/pi-intercom/bridge-storage.ts +83 -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 +11 -2
|
@@ -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
|
}
|
|
@@ -64,6 +64,7 @@ class Bridge {
|
|
|
64
64
|
this.presence = new PersistentPresence({
|
|
65
65
|
stateDir: config.stateDir,
|
|
66
66
|
clients: () => this.config.clients,
|
|
67
|
+
restored: (identity, expires) => this.deliveries.session(identity, expires),
|
|
67
68
|
connect: async (identity, expires) => {
|
|
68
69
|
await spawnBrokerIfNeeded(process.execPath, []);
|
|
69
70
|
if (!this.allowed(identity)) {
|
|
@@ -153,6 +154,7 @@ class Bridge {
|
|
|
153
154
|
{
|
|
154
155
|
stateDir: this.config.stateDir,
|
|
155
156
|
notifications: identity.wake !== undefined,
|
|
157
|
+
mailboxChanged: () => this.deliveries.changed(),
|
|
156
158
|
allowed: (candidate) => this.allowed(candidate),
|
|
157
159
|
lost: (fp, count) => this.lost(fp, count),
|
|
158
160
|
losses: (fp) => this.losses.get(fp) ?? 0,
|
|
@@ -299,6 +301,9 @@ class Bridge {
|
|
|
299
301
|
this.losses.delete(fp);
|
|
300
302
|
}
|
|
301
303
|
}
|
|
304
|
+
// Retire stale sessions before publishing replacement delivery membership for the same pin.
|
|
305
|
+
this.presence?.restoreAll();
|
|
306
|
+
this.deliveries.changed();
|
|
302
307
|
}
|
|
303
308
|
private stop(): void {
|
|
304
309
|
this.shutdown().catch(() => {
|
|
@@ -363,8 +368,11 @@ class Bridge {
|
|
|
363
368
|
let controller: AbortController | undefined;
|
|
364
369
|
let deadline: NodeJS.Timeout | undefined;
|
|
365
370
|
let peer: string | undefined;
|
|
371
|
+
let readOnlyInbox = false;
|
|
372
|
+
const cancel = (fault: Readonly<Fault>) =>
|
|
373
|
+
readOnlyInbox ? controller?.abort(fault) : session?.close(fault);
|
|
366
374
|
const abort = () =>
|
|
367
|
-
|
|
375
|
+
cancel(
|
|
368
376
|
new Fault(499, "client_aborted", "HTTP client disconnected; delivery may be unknown."),
|
|
369
377
|
);
|
|
370
378
|
const onClose = () => {
|
|
@@ -376,7 +384,7 @@ class Bridge {
|
|
|
376
384
|
clearTimeout(deadline);
|
|
377
385
|
deadline = setTimeout(
|
|
378
386
|
() =>
|
|
379
|
-
|
|
387
|
+
cancel(
|
|
380
388
|
new Fault(
|
|
381
389
|
504,
|
|
382
390
|
"request_timeout",
|
|
@@ -389,6 +397,7 @@ class Bridge {
|
|
|
389
397
|
try {
|
|
390
398
|
const { identity, expires, certificate } = this.admission(req);
|
|
391
399
|
const route = routeFor(req);
|
|
400
|
+
readOnlyInbox = route === "GET /v1/inbox";
|
|
392
401
|
session = this.session(identity, expires);
|
|
393
402
|
controller = session.begin();
|
|
394
403
|
const signal = controller.signal;
|