@fitchmultz/pi-subagents 0.46.2 → 0.47.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.
@@ -1,7 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { IntercomClient } from "./broker/client.js";
3
3
  import { formatSessionTarget, resolveSessionProjectId, resolveSessionTarget, } from "./session-targets.js";
4
- import { Fault, INBOX_BYTES, INBOX_COUNT, cancelled, log, wait } from "./bridge-protocol.js";
4
+ import { Fault, cancelled, log, wait } from "./bridge-protocol.js";
5
+ import { Mailbox } from "./bridge-mailbox.js";
5
6
  import { WaitingAsk } from "./bridge-ask.js";
6
7
  const MAX_REQUESTS = 8;
7
8
  const IDLE_TIMEOUT = 5 * 60 * 1000;
@@ -17,8 +18,8 @@ export class Session {
17
18
  listTimeoutMs: LIST_TIMEOUT,
18
19
  });
19
20
  requests = new Set();
20
- inbox = new Map();
21
- inboxBytes = 0;
21
+ inbox;
22
+ replying = new Set();
22
23
  pending;
23
24
  closed = false;
24
25
  connecting;
@@ -30,6 +31,7 @@ export class Session {
30
31
  this.owner = owner;
31
32
  this.identity = identity;
32
33
  this.expires = expires;
34
+ this.inbox = new Mailbox(owner.stateDir, identity.fingerprint256, owner.notifications);
33
35
  this.client.on("error", () => this.close(new Fault(503, "broker_unavailable", "Broker connection failed.")));
34
36
  this.client.on("disconnected", () => this.close(new Fault(503, "broker_disconnected", "Broker disconnected.")));
35
37
  this.client.on("_registered", () => {
@@ -102,6 +104,7 @@ export class Session {
102
104
  this.close(fault);
103
105
  throw fault;
104
106
  }
107
+ this.presence();
105
108
  log("registered", this.fields());
106
109
  }
107
110
  async connect(signal) {
@@ -136,6 +139,7 @@ export class Session {
136
139
  // The original request receives the connection failure; teardown must still close a late socket.
137
140
  }
138
141
  await this.disconnect();
142
+ this.owner.lost(this.identity.fingerprint256, this.inbox.close());
139
143
  this.owner.retired(this.identity.fingerprint256, this.generation);
140
144
  log("session_closed", { ...this.fields(), result: this.closeReason.code });
141
145
  }
@@ -150,9 +154,6 @@ export class Session {
150
154
  for (const controller of this.requests) {
151
155
  controller.abort(error);
152
156
  }
153
- this.owner.lost(this.identity.fingerprint256, this.inbox.size);
154
- this.inbox.clear();
155
- this.inboxBytes = 0;
156
157
  // Retain the generation until acquisition/registration AND final disconnect settle.
157
158
  this.startRetirement();
158
159
  }
@@ -166,12 +167,6 @@ export class Session {
166
167
  if (this.pending?.peer === id && !this.pending.settled) {
167
168
  this.pending.reject(new Fault(409, "peer_offline", "Ask recipient disconnected.", { id: this.pending.id }));
168
169
  }
169
- for (const [messageId, entry] of this.inbox) {
170
- if (entry.from.id === id && entry.message.expectsReply === true) {
171
- this.remove(messageId);
172
- this.owner.lost(this.identity.fingerprint256);
173
- }
174
- }
175
170
  this.presence();
176
171
  }
177
172
  receive(from, message) {
@@ -187,48 +182,57 @@ export class Session {
187
182
  : new Fault(403, "revoked", "Identity is no longer authorized."));
188
183
  return;
189
184
  }
190
- log("received", { ...this.fields(), peer: from.id, messageId: message.id, result: "received" });
191
- const pending = this.pending;
192
- if (pending?.matches(from.id, message.replyTo) === true) {
193
- pending.resolve({ from, message });
185
+ if (!this.inbox.persistent && this.resolveReply(from, message)) {
194
186
  return;
195
187
  }
196
- if (this.inbox.has(message.id)) {
197
- this.owner.lost(this.identity.fingerprint256);
198
- return;
188
+ this.storeReceive(from, message);
189
+ }
190
+ storeReceive(from, message) {
191
+ let result;
192
+ try {
193
+ result = this.inbox.add({ from, message });
199
194
  }
200
- const bytes = Buffer.byteLength(JSON.stringify({ from, message }));
201
- if (this.inbox.size >= INBOX_COUNT || this.inboxBytes + bytes > INBOX_BYTES) {
202
- this.owner.lost(this.identity.fingerprint256);
195
+ catch {
203
196
  log("inbox_overflow", {
204
197
  ...this.fields(),
205
198
  peer: from.id,
206
199
  messageId: message.id,
207
- result: "lost",
200
+ result: "storage_failed",
201
+ lossRecorded: false,
208
202
  });
203
+ this.close(new Fault(507, "mailbox_storage_failed", "Mailbox storage failed; inspect the metadata audit for unrecorded losses."));
209
204
  return;
210
205
  }
211
- // ponytail: bounded volatile inbox; use a private durable journal if restart/lease-loss recovery is required.
212
- this.inbox.set(message.id, { from, message, bytes, replying: false });
213
- this.inboxBytes += bytes;
206
+ if (result === "lost" || (result === "duplicate" && !this.inbox.persistent)) {
207
+ this.owner.lost(this.identity.fingerprint256);
208
+ }
209
+ this.recordReceive(from.id, message.id, result);
210
+ if (result !== "lost") {
211
+ this.resolveReply(from, message);
212
+ }
214
213
  this.presence();
215
214
  }
216
- remove(id) {
217
- const entry = this.inbox.get(id);
218
- if (entry) {
219
- this.inboxBytes -= entry.bytes;
220
- this.inbox.delete(id);
215
+ resolveReply(from, message) {
216
+ const pending = this.pending;
217
+ if (pending?.matches(from.id, message.replyTo) !== true) {
218
+ return false;
221
219
  }
220
+ pending.resolve({ from, message });
221
+ return true;
222
+ }
223
+ recordReceive(peer, messageId, result) {
224
+ log(result === "lost" ? "inbox_overflow" : "received", {
225
+ ...this.fields(),
226
+ peer,
227
+ messageId,
228
+ result: result === "lost" && this.inbox.persistent ? "storage_failed" : result,
229
+ });
222
230
  }
223
231
  presence() {
224
232
  if (this.closed) {
225
233
  return;
226
234
  }
227
- const pendingAsks = [...this.inbox.values()].filter((entry) => entry.message.expectsReply === true).length;
228
- this.client.updatePresence({
229
- pendingAsks,
230
- acceptsAsks: this.inbox.size < INBOX_COUNT && this.inboxBytes < INBOX_BYTES,
231
- });
235
+ this.client.updatePresence(this.inbox.presence());
232
236
  }
233
237
  async peers(signal) {
234
238
  this.guard(signal);
@@ -337,30 +341,19 @@ export class Session {
337
341
  }
338
342
  }
339
343
  ack(ids) {
340
- const acked = [];
341
- const retained = [];
342
- for (const id of ids) {
343
- const entry = this.inbox.get(id);
344
- if (entry?.message.expectsReply === true) {
345
- retained.push(id);
346
- }
347
- else if (entry) {
348
- this.remove(id);
349
- acked.push(id);
350
- }
351
- }
344
+ const result = this.inbox.ack(ids);
352
345
  this.presence();
353
- return { acked, retained };
346
+ return result;
354
347
  }
355
348
  async answer(replyTo, message, context) {
356
349
  const entry = this.inbox.get(replyTo);
357
350
  if (entry?.message.expectsReply !== true) {
358
351
  throw new Fault(404, "unknown_ask", "replyTo must identify an unanswered inbound ask.");
359
352
  }
360
- if (entry.replying) {
353
+ if (this.replying.has(replyTo)) {
361
354
  throw new Fault(409, "reply_in_progress", "This ask already has a reply in flight.");
362
355
  }
363
- entry.replying = true;
356
+ this.replying.add(replyTo);
364
357
  const peer = entry.from.id;
365
358
  context.onPeer(peer);
366
359
  try {
@@ -369,16 +362,16 @@ export class Session {
369
362
  throw new Fault(409, "peer_offline", "Original ask sender is disconnected.");
370
363
  }
371
364
  this.guard(context.signal);
372
- if (this.inbox.get(replyTo) !== entry) {
365
+ if (!this.inbox.get(replyTo)) {
373
366
  throw new Fault(409, "ask_retired", "Inbound ask is no longer replyable.");
374
367
  }
375
368
  const data = { ...(await this.send(peer, message, context, { replyTo })) };
376
- this.remove(replyTo);
369
+ this.inbox.remove(replyTo);
377
370
  this.presence();
378
371
  return data;
379
372
  }
380
373
  finally {
381
- entry.replying = false;
374
+ this.replying.delete(replyTo);
382
375
  }
383
376
  }
384
377
  async execute(operation, context) {
@@ -386,6 +379,19 @@ export class Session {
386
379
  if (operation.route === "POST /v1/ask") {
387
380
  return this.ask(operation, context);
388
381
  }
382
+ if (operation.route === "GET /v1/inbox" || operation.route === "POST /v1/ack") {
383
+ // Durable reads/acks remain available while the local broker is down.
384
+ if (!this.inbox.persistent) {
385
+ await this.connect(context.signal);
386
+ }
387
+ if (operation.route === "POST /v1/ack") {
388
+ return this.ack(operation.ids);
389
+ }
390
+ return {
391
+ ...this.inbox.page(operation.after, operation.limit),
392
+ ...this.inbox.status(this.owner.losses(this.identity.fingerprint256)),
393
+ };
394
+ }
389
395
  await this.connect(context.signal);
390
396
  switch (operation.route) {
391
397
  case "POST /v1/register":
@@ -399,16 +405,6 @@ export class Session {
399
405
  }),
400
406
  };
401
407
  }
402
- case "GET /v1/inbox": {
403
- const lostMessages = this.owner.losses(this.identity.fingerprint256);
404
- return {
405
- messages: [...this.inbox.values()].map(({ from, message }) => ({ from, message })),
406
- overflow: lostMessages > 0,
407
- lostMessages,
408
- };
409
- }
410
- case "POST /v1/ack":
411
- return this.ack(operation.ids);
412
408
  case "POST /v1/send":
413
409
  return {
414
410
  ...(await this.send((await this.target(operation.to, context.signal)).id, operation.message, context)),
@@ -0,0 +1,158 @@
1
+ import { log } from "./bridge-protocol.js";
2
+ import { Mailbox } from "./bridge-mailbox.js";
3
+ const POLL_MS = 15000;
4
+ const FALLBACK_AFTER_MS = 120000;
5
+ const MAX_BACKOFF_MS = 300000;
6
+ export class WakeDelivery {
7
+ controller = new AbortController();
8
+ timer;
9
+ running;
10
+ backoff = POLL_MS;
11
+ name;
12
+ mailbox;
13
+ policy;
14
+ authorized;
15
+ constructor(name, mailbox, policy, authorized) {
16
+ this.name = name;
17
+ this.mailbox = mailbox;
18
+ this.policy = policy;
19
+ this.authorized = authorized;
20
+ }
21
+ start() {
22
+ if (!this.running && !this.controller.signal.aborted) {
23
+ this.schedule(0);
24
+ }
25
+ }
26
+ schedule(delay) {
27
+ clearTimeout(this.timer);
28
+ this.timer = setTimeout(() => {
29
+ this.running = this.deliver()
30
+ .catch(() => {
31
+ log("wake_delivery", { name: this.name, result: "storage_unavailable" });
32
+ this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
33
+ })
34
+ .finally(() => {
35
+ this.running = undefined;
36
+ if (!this.controller.signal.aborted) {
37
+ this.schedule(this.backoff);
38
+ }
39
+ });
40
+ }, delay);
41
+ }
42
+ async deliver() {
43
+ if (!this.authorized()) {
44
+ return;
45
+ }
46
+ const entries = this.mailbox.pendingNotifications().messages;
47
+ for (const entry of entries) {
48
+ if (entry.acknowledged) {
49
+ // The box's durable worker owns primary wake/replay after acknowledgement.
50
+ this.mailbox.notificationAccepted(entry.message.id);
51
+ }
52
+ }
53
+ const next = entries.find((entry) => !entry.acknowledged && Date.now() - entry.receivedAt >= FALLBACK_AFTER_MS);
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;
60
+ }
61
+ async post(entry) {
62
+ if (!this.authorized()) {
63
+ return;
64
+ }
65
+ const fields = { name: this.name, messageId: entry.message.id };
66
+ try {
67
+ const response = await fetch(this.policy.url, {
68
+ method: "POST",
69
+ redirect: "error",
70
+ headers: {
71
+ Authorization: `Bearer ${this.policy.key}`,
72
+ "Content-Type": "application/json",
73
+ },
74
+ body: JSON.stringify({
75
+ from: entry.from.name ?? entry.from.id,
76
+ from_id: entry.from.id,
77
+ to: this.name,
78
+ message_id: entry.message.id,
79
+ ask_id: entry.message.expectsReply === true ? entry.message.id : null,
80
+ text: entry.message.content.text,
81
+ }),
82
+ signal: AbortSignal.any([this.controller.signal, AbortSignal.timeout(10000)]),
83
+ });
84
+ await response.body?.cancel();
85
+ if (response.ok && !this.controller.signal.aborted && this.authorized()) {
86
+ // HTTP acceptance cannot prove durable intake; the same ID remains pending until inbox ACK.
87
+ this.backoff = MAX_BACKOFF_MS;
88
+ log("wake_delivery", { ...fields, result: "accepted" });
89
+ }
90
+ else {
91
+ this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
92
+ log("wake_delivery", { ...fields, result: "http_rejected", status: response.status });
93
+ }
94
+ }
95
+ catch {
96
+ this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
97
+ if (!this.controller.signal.aborted) {
98
+ log("wake_delivery", { ...fields, result: "transport_unconfirmed" });
99
+ }
100
+ }
101
+ }
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
+ drop(fp) {
129
+ const delivery = this.deliveries.get(fp);
130
+ if (!delivery) {
131
+ return;
132
+ }
133
+ this.deliveries.delete(fp);
134
+ const cleanup = delivery.worker
135
+ .stop()
136
+ .then(() => delivery.mailbox.close())
137
+ .catch(() => {
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);
144
+ }
145
+ reload() {
146
+ for (const [fp, delivery] of this.deliveries) {
147
+ if (!this.allowed(delivery.identity)) {
148
+ this.drop(fp);
149
+ }
150
+ }
151
+ }
152
+ async stop() {
153
+ for (const fp of this.deliveries.keys()) {
154
+ this.drop(fp);
155
+ }
156
+ await Promise.all(this.cleanup);
157
+ }
158
+ }
@@ -9,7 +9,16 @@ import { Session } from "./bridge-session.js";
9
9
  import { operationFor, routeFor } from "./bridge-routes.js";
10
10
  import { reply } from "./bridge-http.js";
11
11
  import { Launcher, sameIdentity } from "./bridge-launch.js";
12
+ import { PersistentPresence } from "./bridge-presence.js";
13
+ import { WakeDeliveries } from "./bridge-wake.js";
14
+ import { spawnBrokerIfNeeded } from "./broker/spawn.js";
12
15
  const MAX_CONNECTIONS = 128;
16
+ function validCertificateDates(starts, expires) {
17
+ return (Number.isFinite(starts) &&
18
+ Number.isFinite(expires) &&
19
+ starts <= Date.now() &&
20
+ expires > Date.now());
21
+ }
13
22
  class Bridge {
14
23
  config;
15
24
  configPath;
@@ -21,9 +30,32 @@ class Bridge {
21
30
  server;
22
31
  stopping = false;
23
32
  handlers = new Set();
33
+ presence;
34
+ deliveries;
24
35
  constructor(config, configPath) {
25
36
  this.config = config;
26
37
  this.configPath = configPath;
38
+ this.deliveries = new WakeDeliveries(config.stateDir, (identity) => this.allowed(identity));
39
+ if (config.stateDir !== undefined) {
40
+ this.presence = new PersistentPresence({
41
+ stateDir: config.stateDir,
42
+ clients: () => this.config.clients,
43
+ connect: async (identity, expires) => {
44
+ await spawnBrokerIfNeeded(process.execPath, []);
45
+ if (!this.allowed(identity)) {
46
+ return;
47
+ }
48
+ const session = this.session(identity, expires);
49
+ const controller = session.begin();
50
+ try {
51
+ await session.connectedPeers(controller.signal);
52
+ }
53
+ finally {
54
+ session.end(controller);
55
+ }
56
+ },
57
+ });
58
+ }
27
59
  this.server = createServer({
28
60
  ...config.tls,
29
61
  minVersion: "TLSv1.2",
@@ -74,7 +106,9 @@ class Bridge {
74
106
  }
75
107
  allowed(identity) {
76
108
  const current = this.config.clients.get(identity.fingerprint256);
77
- return !this.stopping && sameIdentity(identity, current);
109
+ return (!this.stopping &&
110
+ sameIdentity(identity, current) &&
111
+ JSON.stringify(identity.wake) === JSON.stringify(current?.wake));
78
112
  }
79
113
  lost(fp, count = 1) {
80
114
  if (count > 0 && this.config.clients.has(fp)) {
@@ -82,9 +116,12 @@ class Bridge {
82
116
  }
83
117
  }
84
118
  session(identity, expires) {
119
+ this.deliveries.session(identity, expires);
85
120
  let session = this.sessions.get(identity.fingerprint256);
86
121
  if (!session) {
87
122
  session = new Session({
123
+ stateDir: this.config.stateDir,
124
+ notifications: identity.wake !== undefined,
88
125
  allowed: (candidate) => this.allowed(candidate),
89
126
  lost: (fp, count) => this.lost(fp, count),
90
127
  losses: (fp) => this.losses.get(fp) ?? 0,
@@ -114,14 +151,19 @@ class Bridge {
114
151
  if (!socket.authorized ||
115
152
  !identity ||
116
153
  this.stopping ||
117
- !Number.isFinite(expires) ||
118
- !Number.isFinite(starts) ||
119
- starts > Date.now() ||
120
- expires <= Date.now()) {
154
+ !validCertificateDates(starts, expires)) {
121
155
  log("connection_rejected", { fingerprint: fp, result: "unauthorized" });
122
156
  socket.destroy();
123
157
  return;
124
158
  }
159
+ try {
160
+ this.presence?.authenticated(identity, expires);
161
+ }
162
+ catch {
163
+ log("connection_rejected", { fingerprint: fp, result: "presence_storage_failed" });
164
+ socket.destroy();
165
+ return;
166
+ }
125
167
  const expiry = () => {
126
168
  if (expires <= Date.now()) {
127
169
  this.drop(fp, new Fault(403, "certificate_expired", "Client certificate expired."));
@@ -143,6 +185,7 @@ class Bridge {
143
185
  log("connected", { name: identity.name, fingerprint: fp, result: "authenticated" });
144
186
  }
145
187
  drop(fp, error) {
188
+ this.deliveries.drop(fp);
146
189
  this.sessions.get(fp)?.close(error);
147
190
  for (const [socket, connection] of this.connections) {
148
191
  if (connection.fingerprint === fp) {
@@ -160,6 +203,7 @@ class Bridge {
160
203
  port: typeof address === "object" && address ? address.port : this.config.port,
161
204
  brokerSocket: getBrokerSocketPath(),
162
205
  });
206
+ this.presence?.start();
163
207
  });
164
208
  process.on("SIGHUP", () => this.reload());
165
209
  // Retain listeners during dispatch: selected SDK dependencies use signal-exit's listener check.
@@ -182,6 +226,7 @@ class Bridge {
182
226
  }
183
227
  catch {
184
228
  this.config = { ...this.config, clients: new Map() };
229
+ this.deliveries.reload();
185
230
  this.launcher
186
231
  .revoke(this.config.clients)
187
232
  .catch(() => log("launch_cleanup", { result: "failed" }));
@@ -196,6 +241,7 @@ class Bridge {
196
241
  }
197
242
  }
198
243
  revokeRemoved() {
244
+ this.deliveries.reload();
199
245
  for (const [fp, session] of this.sessions) {
200
246
  if (!this.allowed(session.identity)) {
201
247
  this.drop(fp, new Fault(403, "revoked", "Identity revoked or changed by configuration reload."));
@@ -235,6 +281,8 @@ class Bridge {
235
281
  ...this.handlers,
236
282
  ...sessions.map((session) => session.settled()),
237
283
  this.launcher.shutdown(),
284
+ this.presence?.stop(),
285
+ this.deliveries.stop(),
238
286
  ]);
239
287
  log("shutdown", { result: "closed" });
240
288
  }
@@ -345,7 +393,7 @@ class Bridge {
345
393
  function main() {
346
394
  const args = process.argv.slice(2);
347
395
  if (args.length === 1 && (args[0] === "-h" || args[0] === "--help")) {
348
- process.stdout.write("Usage: node dist/pi-intercom/bridge.js --config PATH\n\nOptional loopback HTTPS/mTLS bridge for the local pi-intercom broker.\nConfig and TLS key must be owned by you with mode 0600. Never starts the broker.\nSIGHUP reloads the client allowlist (invalid reload denies all clients).\nRestart this helper for TLS/listener rotation. SIGINT/SIGTERM disconnect peers.\n\nExample: node dist/pi-intercom/bridge.js --config ~/.pi/agent/intercom/bridge.json\n");
396
+ process.stdout.write("Usage: node dist/pi-intercom/bridge.js --config PATH\n\nOptional loopback HTTPS/mTLS bridge for the local pi-intercom broker.\nConfig and TLS key must be owned by you with mode 0600.\nWithout stateDir, messaging never starts the broker. Durable presence safely starts\none only when none is running; it never restarts or replaces a live broker.\nSIGHUP reloads the client allowlist (invalid reload denies all clients).\nRestart this helper for TLS/listener rotation. SIGINT/SIGTERM disconnect peers.\n\nExample: node dist/pi-intercom/bridge.js --config ~/.pi/agent/intercom/bridge.json\n");
349
397
  return;
350
398
  }
351
399
  if (args.length !== 2 || args[0] !== "--config" || args[1] === "") {
@@ -358,7 +406,7 @@ function main() {
358
406
  new Bridge(loadConfig(path), path).start();
359
407
  }
360
408
  catch {
361
- process.stderr.write("Bridge startup failed: check config schema, file ownership/mode 0600, absolute TLS/cwd paths, TLS credentials, and launch policy (0700 sessionDir, Pi packageRoot, extension paths).\n");
409
+ process.stderr.write("Bridge startup failed: check config schema, file ownership/mode 0600, absolute TLS/cwd paths, TLS credentials, stateDir (owned 0700 directory), and launch policy (0700 sessionDir, Pi packageRoot, extension paths).\n");
362
410
  process.exitCode = 1;
363
411
  }
364
412
  }