@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.
@@ -7,20 +7,17 @@ import {
7
7
  } from "./session-targets.ts";
8
8
  import type { Message, SendResult, SessionInfo } from "./types.ts";
9
9
  import type { Identity } from "./bridge-config.ts";
10
- import { Fault, INBOX_BYTES, INBOX_COUNT, cancelled, log, wait } from "./bridge-protocol.ts";
10
+ import { Fault, cancelled, log, wait } from "./bridge-protocol.ts";
11
+ import { Mailbox } from "./bridge-mailbox.ts";
11
12
  import type { UnknownRecord } from "../shared/unknown.ts";
12
13
  import type { MessagingOperation as Operation } from "./bridge-routes.ts";
13
14
 
14
- import { WaitingAsk, type Envelope } from "./bridge-ask.ts";
15
+ import { WaitingAsk } from "./bridge-ask.ts";
15
16
  const MAX_REQUESTS = 8;
16
17
  const IDLE_TIMEOUT = 5 * 60 * 1000;
17
18
  const SEND_TIMEOUT = 8000;
18
19
  const LIST_TIMEOUT = 5000;
19
20
 
20
- interface InboxEntry extends Envelope {
21
- readonly bytes: number;
22
- replying: boolean;
23
- }
24
21
  interface RequestContext {
25
22
  readonly signal: AbortSignal;
26
23
  readonly requestId: string;
@@ -32,6 +29,8 @@ interface SendOptions {
32
29
  readonly expectsReply?: boolean;
33
30
  }
34
31
  export interface SessionOwner {
32
+ readonly stateDir?: string;
33
+ readonly notifications?: boolean;
35
34
  readonly allowed: (identity: Identity) => boolean;
36
35
  readonly lost: (fp: string, count?: number) => void;
37
36
  readonly retired: (fp: string, generation: string) => void;
@@ -48,8 +47,8 @@ export class Session {
48
47
  listTimeoutMs: LIST_TIMEOUT,
49
48
  });
50
49
  private readonly requests = new Set<AbortController>();
51
- private readonly inbox = new Map<string, InboxEntry>();
52
- private inboxBytes = 0;
50
+ private readonly inbox: Mailbox;
51
+ private readonly replying = new Set<string>();
53
52
  private pending?: WaitingAsk;
54
53
  private closed = false;
55
54
  private connecting?: Promise<void>;
@@ -62,6 +61,7 @@ export class Session {
62
61
  this.owner = owner;
63
62
  this.identity = identity;
64
63
  this.expires = expires;
64
+ this.inbox = new Mailbox(owner.stateDir, identity.fingerprint256, owner.notifications);
65
65
  this.client.on("error", () =>
66
66
  this.close(new Fault(503, "broker_unavailable", "Broker connection failed.")),
67
67
  );
@@ -153,6 +153,7 @@ export class Session {
153
153
  this.close(fault);
154
154
  throw fault;
155
155
  }
156
+ this.presence();
156
157
  log("registered", this.fields());
157
158
  }
158
159
  private async connect(signal: AbortSignal): Promise<void> {
@@ -192,6 +193,7 @@ export class Session {
192
193
  // The original request receives the connection failure; teardown must still close a late socket.
193
194
  }
194
195
  await this.disconnect();
196
+ this.owner.lost(this.identity.fingerprint256, this.inbox.close());
195
197
  this.owner.retired(this.identity.fingerprint256, this.generation);
196
198
  log("session_closed", { ...this.fields(), result: this.closeReason.code });
197
199
  }
@@ -206,9 +208,6 @@ export class Session {
206
208
  for (const controller of this.requests) {
207
209
  controller.abort(error);
208
210
  }
209
- this.owner.lost(this.identity.fingerprint256, this.inbox.size);
210
- this.inbox.clear();
211
- this.inboxBytes = 0;
212
211
  // Retain the generation until acquisition/registration AND final disconnect settle.
213
212
  this.startRetirement();
214
213
  }
@@ -225,12 +224,6 @@ export class Session {
225
224
  new Fault(409, "peer_offline", "Ask recipient disconnected.", { id: this.pending.id }),
226
225
  );
227
226
  }
228
- for (const [messageId, entry] of this.inbox) {
229
- if (entry.from.id === id && entry.message.expectsReply === true) {
230
- this.remove(messageId);
231
- this.owner.lost(this.identity.fingerprint256);
232
- }
233
- }
234
227
  this.presence();
235
228
  }
236
229
  private receive(from: SessionInfo, message: Message): void {
@@ -247,50 +240,66 @@ export class Session {
247
240
  );
248
241
  return;
249
242
  }
250
- log("received", { ...this.fields(), peer: from.id, messageId: message.id, result: "received" });
251
- const pending = this.pending;
252
- if (pending?.matches(from.id, message.replyTo) === true) {
253
- pending.resolve({ from, message });
243
+ if (!this.inbox.persistent && this.resolveReply(from, message)) {
254
244
  return;
255
245
  }
256
- if (this.inbox.has(message.id)) {
257
- this.owner.lost(this.identity.fingerprint256);
258
- return;
259
- }
260
- const bytes = Buffer.byteLength(JSON.stringify({ from, message }));
261
- if (this.inbox.size >= INBOX_COUNT || this.inboxBytes + bytes > INBOX_BYTES) {
262
- this.owner.lost(this.identity.fingerprint256);
246
+ this.storeReceive(from, message);
247
+ }
248
+ private storeReceive(from: SessionInfo, message: Message): void {
249
+ let result: "stored" | "duplicate" | "lost";
250
+ try {
251
+ result = this.inbox.add({ from, message });
252
+ } catch {
263
253
  log("inbox_overflow", {
264
254
  ...this.fields(),
265
255
  peer: from.id,
266
256
  messageId: message.id,
267
- result: "lost",
257
+ result: "storage_failed",
258
+ lossRecorded: false,
268
259
  });
260
+ this.close(
261
+ new Fault(
262
+ 507,
263
+ "mailbox_storage_failed",
264
+ "Mailbox storage failed; inspect the metadata audit for unrecorded losses.",
265
+ ),
266
+ );
269
267
  return;
270
268
  }
271
- // ponytail: bounded volatile inbox; use a private durable journal if restart/lease-loss recovery is required.
272
- this.inbox.set(message.id, { from, message, bytes, replying: false });
273
- this.inboxBytes += bytes;
269
+ if (result === "lost" || (result === "duplicate" && !this.inbox.persistent)) {
270
+ this.owner.lost(this.identity.fingerprint256);
271
+ }
272
+ this.recordReceive(from.id, message.id, result);
273
+ if (result !== "lost") {
274
+ this.resolveReply(from, message);
275
+ }
274
276
  this.presence();
275
277
  }
276
- private remove(id: string): void {
277
- const entry = this.inbox.get(id);
278
- if (entry) {
279
- this.inboxBytes -= entry.bytes;
280
- this.inbox.delete(id);
278
+ private resolveReply(from: SessionInfo, message: Message): boolean {
279
+ const pending = this.pending;
280
+ if (pending?.matches(from.id, message.replyTo) !== true) {
281
+ return false;
281
282
  }
283
+ pending.resolve({ from, message });
284
+ return true;
285
+ }
286
+ private recordReceive(
287
+ peer: string,
288
+ messageId: string,
289
+ result: "stored" | "duplicate" | "lost",
290
+ ): void {
291
+ log(result === "lost" ? "inbox_overflow" : "received", {
292
+ ...this.fields(),
293
+ peer,
294
+ messageId,
295
+ result: result === "lost" && this.inbox.persistent ? "storage_failed" : result,
296
+ });
282
297
  }
283
298
  private presence(): void {
284
299
  if (this.closed) {
285
300
  return;
286
301
  }
287
- const pendingAsks = [...this.inbox.values()].filter(
288
- (entry) => entry.message.expectsReply === true,
289
- ).length;
290
- this.client.updatePresence({
291
- pendingAsks,
292
- acceptsAsks: this.inbox.size < INBOX_COUNT && this.inboxBytes < INBOX_BYTES,
293
- });
302
+ this.client.updatePresence(this.inbox.presence());
294
303
  }
295
304
  private async peers(signal: AbortSignal): Promise<SessionInfo[]> {
296
305
  this.guard(signal);
@@ -421,19 +430,9 @@ export class Session {
421
430
  }
422
431
  }
423
432
  private ack(ids: readonly string[]): UnknownRecord {
424
- const acked: string[] = [];
425
- const retained: string[] = [];
426
- for (const id of ids) {
427
- const entry = this.inbox.get(id);
428
- if (entry?.message.expectsReply === true) {
429
- retained.push(id);
430
- } else if (entry) {
431
- this.remove(id);
432
- acked.push(id);
433
- }
434
- }
433
+ const result = this.inbox.ack(ids);
435
434
  this.presence();
436
- return { acked, retained };
435
+ return result;
437
436
  }
438
437
  private async answer(
439
438
  replyTo: string,
@@ -444,10 +443,10 @@ export class Session {
444
443
  if (entry?.message.expectsReply !== true) {
445
444
  throw new Fault(404, "unknown_ask", "replyTo must identify an unanswered inbound ask.");
446
445
  }
447
- if (entry.replying) {
446
+ if (this.replying.has(replyTo)) {
448
447
  throw new Fault(409, "reply_in_progress", "This ask already has a reply in flight.");
449
448
  }
450
- entry.replying = true;
449
+ this.replying.add(replyTo);
451
450
  const peer = entry.from.id;
452
451
  context.onPeer(peer);
453
452
  try {
@@ -456,15 +455,15 @@ export class Session {
456
455
  throw new Fault(409, "peer_offline", "Original ask sender is disconnected.");
457
456
  }
458
457
  this.guard(context.signal);
459
- if (this.inbox.get(replyTo) !== entry) {
458
+ if (!this.inbox.get(replyTo)) {
460
459
  throw new Fault(409, "ask_retired", "Inbound ask is no longer replyable.");
461
460
  }
462
461
  const data = { ...(await this.send(peer, message, context, { replyTo })) };
463
- this.remove(replyTo);
462
+ this.inbox.remove(replyTo);
464
463
  this.presence();
465
464
  return data;
466
465
  } finally {
467
- entry.replying = false;
466
+ this.replying.delete(replyTo);
468
467
  }
469
468
  }
470
469
  async execute(operation: Operation, context: RequestContext): Promise<UnknownRecord> {
@@ -472,6 +471,19 @@ export class Session {
472
471
  if (operation.route === "POST /v1/ask") {
473
472
  return this.ask(operation, context);
474
473
  }
474
+ if (operation.route === "GET /v1/inbox" || operation.route === "POST /v1/ack") {
475
+ // Durable reads/acks remain available while the local broker is down.
476
+ if (!this.inbox.persistent) {
477
+ await this.connect(context.signal);
478
+ }
479
+ if (operation.route === "POST /v1/ack") {
480
+ return this.ack(operation.ids);
481
+ }
482
+ return {
483
+ ...this.inbox.page(operation.after, operation.limit),
484
+ ...this.inbox.status(this.owner.losses(this.identity.fingerprint256)),
485
+ };
486
+ }
475
487
  await this.connect(context.signal);
476
488
  switch (operation.route) {
477
489
  case "POST /v1/register":
@@ -485,16 +497,6 @@ export class Session {
485
497
  }),
486
498
  };
487
499
  }
488
- case "GET /v1/inbox": {
489
- const lostMessages = this.owner.losses(this.identity.fingerprint256);
490
- return {
491
- messages: [...this.inbox.values()].map(({ from, message }) => ({ from, message })),
492
- overflow: lostMessages > 0,
493
- lostMessages,
494
- };
495
- }
496
- case "POST /v1/ack":
497
- return this.ack(operation.ids);
498
500
  case "POST /v1/send":
499
501
  return {
500
502
  ...(await this.send(
@@ -0,0 +1,188 @@
1
+ import { log } from "./bridge-protocol.ts";
2
+ import { Mailbox, type PendingNotification } from "./bridge-mailbox.ts";
3
+ import type { Identity, WakePolicy } from "./bridge-config.ts";
4
+
5
+ const POLL_MS = 15000;
6
+ const FALLBACK_AFTER_MS = 120000;
7
+ const MAX_BACKOFF_MS = 300000;
8
+
9
+ export class WakeDelivery {
10
+ private readonly controller = new AbortController();
11
+ private timer?: NodeJS.Timeout;
12
+ private running?: Promise<void>;
13
+ private backoff = POLL_MS;
14
+ private readonly name: string;
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
+ }
30
+
31
+ start(): void {
32
+ if (!this.running && !this.controller.signal.aborted) {
33
+ this.schedule(0);
34
+ }
35
+ }
36
+
37
+ private schedule(delay: number): void {
38
+ clearTimeout(this.timer);
39
+ this.timer = setTimeout(() => {
40
+ this.running = this.deliver()
41
+ .catch(() => {
42
+ log("wake_delivery", { name: this.name, result: "storage_unavailable" });
43
+ this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
44
+ })
45
+ .finally(() => {
46
+ this.running = undefined;
47
+ if (!this.controller.signal.aborted) {
48
+ this.schedule(this.backoff);
49
+ }
50
+ });
51
+ }, delay);
52
+ }
53
+
54
+ private async deliver(): Promise<void> {
55
+ if (!this.authorized()) {
56
+ return;
57
+ }
58
+ const entries = this.mailbox.pendingNotifications().messages;
59
+ for (const entry of entries) {
60
+ if (entry.acknowledged) {
61
+ // The box's durable worker owns primary wake/replay after acknowledgement.
62
+ this.mailbox.notificationAccepted(entry.message.id);
63
+ }
64
+ }
65
+ const next = entries.find(
66
+ (entry) => !entry.acknowledged && Date.now() - entry.receivedAt >= FALLBACK_AFTER_MS,
67
+ );
68
+ if (next) {
69
+ // One fallback wakes the box to drain the remaining mailbox; never flood a reset VM.
70
+ await this.post(next);
71
+ return;
72
+ }
73
+ this.backoff = POLL_MS;
74
+ }
75
+
76
+ private async post(entry: PendingNotification): Promise<void> {
77
+ if (!this.authorized()) {
78
+ return;
79
+ }
80
+ const fields = { name: this.name, messageId: entry.message.id };
81
+ try {
82
+ const response = await fetch(this.policy.url, {
83
+ method: "POST",
84
+ redirect: "error",
85
+ headers: {
86
+ Authorization: `Bearer ${this.policy.key}`,
87
+ "Content-Type": "application/json",
88
+ },
89
+ body: JSON.stringify({
90
+ from: entry.from.name ?? entry.from.id,
91
+ from_id: entry.from.id,
92
+ to: this.name,
93
+ message_id: entry.message.id,
94
+ ask_id: entry.message.expectsReply === true ? entry.message.id : null,
95
+ text: entry.message.content.text,
96
+ }),
97
+ signal: AbortSignal.any([this.controller.signal, AbortSignal.timeout(10000)]),
98
+ });
99
+ await response.body?.cancel();
100
+ if (response.ok && !this.controller.signal.aborted && this.authorized()) {
101
+ // HTTP acceptance cannot prove durable intake; the same ID remains pending until inbox ACK.
102
+ this.backoff = MAX_BACKOFF_MS;
103
+ log("wake_delivery", { ...fields, result: "accepted" });
104
+ } else {
105
+ this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
106
+ log("wake_delivery", { ...fields, result: "http_rejected", status: response.status });
107
+ }
108
+ } catch {
109
+ this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
110
+ if (!this.controller.signal.aborted) {
111
+ log("wake_delivery", { ...fields, result: "transport_unconfirmed" });
112
+ }
113
+ }
114
+ }
115
+
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
+ drop(fp: string): void {
157
+ const delivery = this.deliveries.get(fp);
158
+ if (!delivery) {
159
+ return;
160
+ }
161
+ this.deliveries.delete(fp);
162
+ const cleanup = delivery.worker
163
+ .stop()
164
+ .then(() => delivery.mailbox.close())
165
+ .catch(() => {
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);
172
+ }
173
+
174
+ reload(): void {
175
+ for (const [fp, delivery] of this.deliveries) {
176
+ if (!this.allowed(delivery.identity)) {
177
+ this.drop(fp);
178
+ }
179
+ }
180
+ }
181
+
182
+ async stop(): Promise<void> {
183
+ for (const fp of this.deliveries.keys()) {
184
+ this.drop(fp);
185
+ }
186
+ await Promise.all(this.cleanup);
187
+ }
188
+ }
@@ -11,9 +11,21 @@ import { Session } from "./bridge-session.ts";
11
11
  import { operationFor, routeFor, type Operation } from "./bridge-routes.ts";
12
12
  import { reply } from "./bridge-http.ts";
13
13
  import { Launcher, sameIdentity } from "./bridge-launch.ts";
14
+ import { PersistentPresence } from "./bridge-presence.ts";
15
+ import { WakeDeliveries } from "./bridge-wake.ts";
16
+ import { spawnBrokerIfNeeded } from "./broker/spawn.ts";
14
17
 
15
18
  const MAX_CONNECTIONS = 128;
16
19
 
20
+ function validCertificateDates(starts: number, expires: number): boolean {
21
+ return (
22
+ Number.isFinite(starts) &&
23
+ Number.isFinite(expires) &&
24
+ starts <= Date.now() &&
25
+ expires > Date.now()
26
+ );
27
+ }
28
+
17
29
  interface Connection {
18
30
  readonly fingerprint: string;
19
31
  readonly expires: number;
@@ -31,10 +43,32 @@ class Bridge {
31
43
  private readonly server: Server;
32
44
  private stopping = false;
33
45
  private readonly handlers = new Set<Promise<void>>();
46
+ private readonly presence?: PersistentPresence;
47
+ private readonly deliveries: WakeDeliveries;
34
48
 
35
49
  constructor(config: Config, configPath: string) {
36
50
  this.config = config;
37
51
  this.configPath = configPath;
52
+ this.deliveries = new WakeDeliveries(config.stateDir, (identity) => this.allowed(identity));
53
+ if (config.stateDir !== undefined) {
54
+ this.presence = new PersistentPresence({
55
+ stateDir: config.stateDir,
56
+ clients: () => this.config.clients,
57
+ connect: async (identity, expires) => {
58
+ await spawnBrokerIfNeeded(process.execPath, []);
59
+ if (!this.allowed(identity)) {
60
+ return;
61
+ }
62
+ const session = this.session(identity, expires);
63
+ const controller = session.begin();
64
+ try {
65
+ await session.connectedPeers(controller.signal);
66
+ } finally {
67
+ session.end(controller);
68
+ }
69
+ },
70
+ });
71
+ }
38
72
  this.server = createServer(
39
73
  {
40
74
  ...config.tls,
@@ -90,7 +124,11 @@ class Bridge {
90
124
  }
91
125
  private allowed(identity: Identity): boolean {
92
126
  const current = this.config.clients.get(identity.fingerprint256);
93
- return !this.stopping && sameIdentity(identity, current);
127
+ return (
128
+ !this.stopping &&
129
+ sameIdentity(identity, current) &&
130
+ JSON.stringify(identity.wake) === JSON.stringify(current?.wake)
131
+ );
94
132
  }
95
133
  private lost(fp: string, count = 1): void {
96
134
  if (count > 0 && this.config.clients.has(fp)) {
@@ -98,10 +136,13 @@ class Bridge {
98
136
  }
99
137
  }
100
138
  private session(identity: Identity, expires: number): Session {
139
+ this.deliveries.session(identity, expires);
101
140
  let session = this.sessions.get(identity.fingerprint256);
102
141
  if (!session) {
103
142
  session = new Session(
104
143
  {
144
+ stateDir: this.config.stateDir,
145
+ notifications: identity.wake !== undefined,
105
146
  allowed: (candidate) => this.allowed(candidate),
106
147
  lost: (fp, count) => this.lost(fp, count),
107
148
  losses: (fp) => this.losses.get(fp) ?? 0,
@@ -134,15 +175,19 @@ class Bridge {
134
175
  !socket.authorized ||
135
176
  !identity ||
136
177
  this.stopping ||
137
- !Number.isFinite(expires) ||
138
- !Number.isFinite(starts) ||
139
- starts > Date.now() ||
140
- expires <= Date.now()
178
+ !validCertificateDates(starts, expires)
141
179
  ) {
142
180
  log("connection_rejected", { fingerprint: fp, result: "unauthorized" });
143
181
  socket.destroy();
144
182
  return;
145
183
  }
184
+ try {
185
+ this.presence?.authenticated(identity, expires);
186
+ } catch {
187
+ log("connection_rejected", { fingerprint: fp, result: "presence_storage_failed" });
188
+ socket.destroy();
189
+ return;
190
+ }
146
191
  const expiry = () => {
147
192
  if (expires <= Date.now()) {
148
193
  this.drop(fp, new Fault(403, "certificate_expired", "Client certificate expired."));
@@ -164,6 +209,7 @@ class Bridge {
164
209
  log("connected", { name: identity.name, fingerprint: fp, result: "authenticated" });
165
210
  }
166
211
  private drop(fp: string, error: Readonly<Fault>): void {
212
+ this.deliveries.drop(fp);
167
213
  this.sessions.get(fp)?.close(error);
168
214
  for (const [socket, connection] of this.connections) {
169
215
  if (connection.fingerprint === fp) {
@@ -181,6 +227,7 @@ class Bridge {
181
227
  port: typeof address === "object" && address ? address.port : this.config.port,
182
228
  brokerSocket: getBrokerSocketPath(),
183
229
  });
230
+ this.presence?.start();
184
231
  });
185
232
  process.on("SIGHUP", () => this.reload());
186
233
  // Retain listeners during dispatch: selected SDK dependencies use signal-exit's listener check.
@@ -202,6 +249,7 @@ class Bridge {
202
249
  log("allowlist_reloaded", { result: "accepted", clients: next.clients.size });
203
250
  } catch {
204
251
  this.config = { ...this.config, clients: new Map() };
252
+ this.deliveries.reload();
205
253
  this.launcher
206
254
  .revoke(this.config.clients)
207
255
  .catch(() => log("launch_cleanup", { result: "failed" }));
@@ -222,6 +270,7 @@ class Bridge {
222
270
  }
223
271
  }
224
272
  private revokeRemoved(): void {
273
+ this.deliveries.reload();
225
274
  for (const [fp, session] of this.sessions) {
226
275
  if (!this.allowed(session.identity)) {
227
276
  this.drop(
@@ -264,6 +313,8 @@ class Bridge {
264
313
  ...this.handlers,
265
314
  ...sessions.map((session) => session.settled()),
266
315
  this.launcher.shutdown(),
316
+ this.presence?.stop(),
317
+ this.deliveries.stop(),
267
318
  ]);
268
319
  log("shutdown", { result: "closed" });
269
320
  }
@@ -402,7 +453,7 @@ function main(): void {
402
453
  const args = process.argv.slice(2);
403
454
  if (args.length === 1 && (args[0] === "-h" || args[0] === "--help")) {
404
455
  process.stdout.write(
405
- "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",
456
+ "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",
406
457
  );
407
458
  return;
408
459
  }
@@ -416,7 +467,7 @@ function main(): void {
416
467
  new Bridge(loadConfig(path), path).start();
417
468
  } catch {
418
469
  process.stderr.write(
419
- "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",
470
+ "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",
420
471
  );
421
472
  process.exitCode = 1;
422
473
  }