@fitchmultz/pi-subagents 0.46.1 → 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.
Files changed (92) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +1 -1
  3. package/dist/agents/agent-management.js +8 -2
  4. package/dist/agents/agents.js +8 -8
  5. package/dist/extension/build-info.js +1 -1
  6. package/dist/extension/index.js +11 -5
  7. package/dist/extension/result-snapshot.js +0 -18
  8. package/dist/history/ingest.js +40 -31
  9. package/dist/history/prune.js +1 -1
  10. package/dist/history/run-projection.js +1 -2
  11. package/dist/history/search-queries.js +8 -2
  12. package/dist/history/store.js +20 -4
  13. package/dist/history/text-chunks.js +1 -2
  14. package/dist/history/transcript-queries.js +12 -6
  15. package/dist/intercom/result-intercom.js +0 -74
  16. package/dist/pi-intercom/bridge-config.js +35 -26
  17. package/dist/pi-intercom/bridge-files.js +47 -0
  18. package/dist/pi-intercom/bridge-mailbox.js +324 -0
  19. package/dist/pi-intercom/bridge-presence.js +100 -0
  20. package/dist/pi-intercom/bridge-routes.js +27 -3
  21. package/dist/pi-intercom/bridge-session.js +60 -64
  22. package/dist/pi-intercom/bridge-wake.js +158 -0
  23. package/dist/pi-intercom/bridge.js +55 -7
  24. package/dist/pi-intercom/broker/broker.js +33 -16
  25. package/dist/pi-intercom/broker/framing.js +67 -17
  26. package/dist/pi-intercom/broker/snapshot-reader.js +29 -8
  27. package/dist/pi-intercom/broker/snapshots.js +144 -41
  28. package/dist/pi-intercom/session-targets.js +0 -1
  29. package/dist/runs/background/async-run-discovery.js +4 -6
  30. package/dist/runs/background/completion-dedupe.js +4 -55
  31. package/dist/runs/background/notify.js +1 -9
  32. package/dist/runs/background/result-watcher.js +1 -1
  33. package/dist/runs/background/runner-child-observer.js +12 -7
  34. package/dist/runs/background/runner-monitor.js +2 -0
  35. package/dist/runs/background/runner-parallel.js +2 -3
  36. package/dist/runs/shared/child-observation-spool.js +8 -3
  37. package/dist/runs/shared/run-restoration.js +4 -5
  38. package/dist/runs/shared/supervisor-questions.js +7 -4
  39. package/dist/shared/journal-frames.js +1 -1
  40. package/dist/slash/command-discovery.js +13 -12
  41. package/dist/tui/agent-history.js +0 -14
  42. package/dist/tui/agent-view.js +0 -1
  43. package/dist/tui/conversation-history.js +10 -1
  44. package/dist/tui/history-cards.js +8 -22
  45. package/docs/intercom-bridge.md +39 -17
  46. package/docs/intercom.md +5 -1
  47. package/docs/performance.md +72 -3
  48. package/package.json +1 -1
  49. package/scripts/intercom-bridge-credentials.mjs +54 -12
  50. package/scripts/intercom-remote.mjs +25 -1
  51. package/scripts/package-smoke.mjs +1 -1
  52. package/src/agents/agent-management.ts +8 -4
  53. package/src/agents/agents.ts +17 -12
  54. package/src/extension/index.ts +12 -5
  55. package/src/extension/result-snapshot.ts +1 -27
  56. package/src/history/ingest.ts +57 -49
  57. package/src/history/prune.ts +1 -1
  58. package/src/history/run-projection.ts +2 -4
  59. package/src/history/search-queries.ts +9 -2
  60. package/src/history/store.ts +21 -5
  61. package/src/history/text-chunks.ts +7 -2
  62. package/src/history/transcript-queries.ts +16 -10
  63. package/src/intercom/result-intercom.ts +0 -103
  64. package/src/pi-intercom/bridge-config.ts +46 -36
  65. package/src/pi-intercom/bridge-files.ts +59 -0
  66. package/src/pi-intercom/bridge-mailbox.ts +395 -0
  67. package/src/pi-intercom/bridge-presence.ts +128 -0
  68. package/src/pi-intercom/bridge-routes.ts +41 -4
  69. package/src/pi-intercom/bridge-session.ts +73 -71
  70. package/src/pi-intercom/bridge-wake.ts +188 -0
  71. package/src/pi-intercom/bridge.ts +58 -7
  72. package/src/pi-intercom/broker/broker.ts +34 -20
  73. package/src/pi-intercom/broker/framing.ts +71 -20
  74. package/src/pi-intercom/broker/snapshot-reader.ts +47 -9
  75. package/src/pi-intercom/broker/snapshots.ts +184 -51
  76. package/src/pi-intercom/session-targets.ts +0 -2
  77. package/src/runs/background/async-run-discovery.ts +4 -10
  78. package/src/runs/background/completion-dedupe.ts +6 -69
  79. package/src/runs/background/notify.ts +2 -12
  80. package/src/runs/background/result-watcher.ts +1 -1
  81. package/src/runs/background/runner-child-observer.ts +19 -11
  82. package/src/runs/background/runner-monitor.ts +2 -0
  83. package/src/runs/background/runner-parallel.ts +2 -3
  84. package/src/runs/shared/child-observation-spool.ts +8 -3
  85. package/src/runs/shared/run-restoration.ts +4 -4
  86. package/src/runs/shared/supervisor-questions.ts +10 -10
  87. package/src/shared/journal-frames.ts +1 -1
  88. package/src/slash/command-discovery.ts +13 -12
  89. package/src/tui/agent-history.ts +1 -18
  90. package/src/tui/agent-view.ts +0 -1
  91. package/src/tui/conversation-history.ts +15 -1
  92. package/src/tui/history-cards.ts +8 -26
@@ -0,0 +1,395 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, realpathSync } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ import { isRecord, type UnknownRecord } from "../shared/unknown.ts";
5
+ import { normalizeMessage, normalizeSessionInfo } from "./types.ts";
6
+ import type { Envelope } from "./bridge-ask.ts";
7
+ import {
8
+ Fault,
9
+ INBOX_BYTES,
10
+ INBOX_COUNT,
11
+ fingerprint as certificateFingerprint,
12
+ label,
13
+ } from "./bridge-protocol.ts";
14
+
15
+ interface Entry extends Envelope {
16
+ readonly sequence: number;
17
+ readonly bytes: number;
18
+ readonly receivedAt: number;
19
+ readonly acked: number;
20
+ }
21
+
22
+ export interface InboxPage {
23
+ readonly messages: readonly Envelope[];
24
+ readonly nextCursor: string;
25
+ readonly hasMore: boolean;
26
+ }
27
+ export interface PendingNotification extends Envelope {
28
+ readonly receivedAt: number;
29
+ readonly acknowledged: boolean;
30
+ }
31
+ export interface NotificationPage extends InboxPage {
32
+ readonly messages: readonly PendingNotification[];
33
+ }
34
+
35
+ export function stateDirectory(value: unknown): string | undefined {
36
+ if (value === undefined) {
37
+ return;
38
+ }
39
+ const path = label(value, "stateDir", 4096);
40
+ const stat = lstatSync(path);
41
+ if (
42
+ !isAbsolute(path) ||
43
+ !stat.isDirectory() ||
44
+ stat.uid !== process.getuid?.() ||
45
+ (stat.mode & 0o777) !== 0o700
46
+ ) {
47
+ throw new Error("stateDir must be an absolute owned directory with mode 0700, not a symlink.");
48
+ }
49
+ return realpathSync(path);
50
+ }
51
+
52
+ function privateFile(path: string, create = false): void {
53
+ const flags = create ? constants.O_RDWR | constants.O_CREAT : constants.O_RDONLY;
54
+ const fd = openSync(path, flags | constants.O_NOFOLLOW, 0o600);
55
+ try {
56
+ const stat = fstatSync(fd);
57
+ if (
58
+ !stat.isFile() ||
59
+ stat.uid !== process.getuid?.() ||
60
+ (stat.mode & 0o777) !== 0o600 ||
61
+ stat.nlink !== 1
62
+ ) {
63
+ throw new Error("Mailbox files must be owned regular files with mode 0600 and one link.");
64
+ }
65
+ } finally {
66
+ closeSync(fd);
67
+ }
68
+ }
69
+
70
+ function database(stateDir: string, fingerprint: string): DatabaseSync {
71
+ stateDirectory(stateDir);
72
+ const path = join(stateDir, `${certificateFingerprint(fingerprint)}.sqlite`);
73
+ privateFile(path, true);
74
+ for (const suffix of ["-journal", "-wal", "-shm"]) {
75
+ try {
76
+ lstatSync(`${path}${suffix}`);
77
+ } catch (error) {
78
+ if (isRecord(error) && error.code === "ENOENT") {
79
+ continue;
80
+ }
81
+ throw error;
82
+ }
83
+ privateFile(`${path}${suffix}`);
84
+ }
85
+ const db = new DatabaseSync(path, { enableDoubleQuotedStringLiterals: false });
86
+ try {
87
+ db.exec(`
88
+ PRAGMA journal_mode=DELETE;
89
+ PRAGMA synchronous=FULL;
90
+ PRAGMA secure_delete=ON;
91
+ PRAGMA cache_size=-1024;
92
+ PRAGMA busy_timeout=1000;
93
+ CREATE TABLE IF NOT EXISTS mailbox (
94
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT,
95
+ id TEXT NOT NULL UNIQUE,
96
+ envelope TEXT,
97
+ bytes INTEGER NOT NULL,
98
+ ask INTEGER NOT NULL,
99
+ receivedAt INTEGER NOT NULL,
100
+ acked INTEGER NOT NULL DEFAULT 0,
101
+ notified INTEGER NOT NULL DEFAULT 1
102
+ );
103
+ CREATE INDEX IF NOT EXISTS unread ON mailbox(sequence) WHERE acked=0;
104
+ CREATE INDEX IF NOT EXISTS asks ON mailbox(ask) WHERE ask=1 AND acked=0;
105
+ CREATE INDEX IF NOT EXISTS notifications ON mailbox(sequence) WHERE notified=0;
106
+ CREATE TABLE IF NOT EXISTS losses (
107
+ singleton INTEGER PRIMARY KEY CHECK(singleton=1),
108
+ count INTEGER NOT NULL
109
+ );
110
+ INSERT OR IGNORE INTO losses VALUES(1, 0);
111
+ `);
112
+ return db;
113
+ } catch (error) {
114
+ db.close();
115
+ throw error;
116
+ }
117
+ }
118
+
119
+ function decode(value: unknown): Envelope {
120
+ const raw: unknown = typeof value === "string" ? JSON.parse(value) : undefined;
121
+ const from = isRecord(raw) ? normalizeSessionInfo(raw.from) : null;
122
+ const message = isRecord(raw) ? normalizeMessage(raw.message) : null;
123
+ if (!from || !message) {
124
+ throw new Fault(507, "mailbox_storage_failed", "Stored mailbox entry is invalid.");
125
+ }
126
+ return { from, message };
127
+ }
128
+
129
+ /** Persistent payloads leave only ID tombstones after acknowledgement, preventing replay resurrection. */
130
+ export class Mailbox {
131
+ private readonly db?: DatabaseSync;
132
+ private readonly entries = new Map<string, Entry>();
133
+ private bytes = 0;
134
+ private sequence = 0;
135
+ private failure?: Fault;
136
+ private admissionFailed = false;
137
+ private readonly notifications: boolean;
138
+
139
+ constructor(stateDir: string | undefined, fingerprint: string, notifications = false) {
140
+ try {
141
+ this.db = stateDir === undefined ? undefined : database(stateDir, fingerprint);
142
+ } catch {
143
+ throw new Fault(507, "mailbox_storage_failed", "Cannot open private mailbox storage.");
144
+ }
145
+ this.notifications = notifications;
146
+ }
147
+ get persistent(): boolean {
148
+ return this.db !== undefined;
149
+ }
150
+ private stored<T>(action: () => T): T {
151
+ if (this.failure) {
152
+ throw this.failure;
153
+ }
154
+ try {
155
+ return action();
156
+ } catch (error) {
157
+ throw error instanceof Fault
158
+ ? error
159
+ : new Fault(507, "mailbox_storage_failed", "Mailbox storage operation failed.");
160
+ }
161
+ }
162
+ add(envelope: Envelope): "stored" | "duplicate" | "lost" {
163
+ if (!this.db) {
164
+ return this.addVolatile(envelope);
165
+ }
166
+ const db = this.db;
167
+ return this.stored(() => {
168
+ try {
169
+ const payload = JSON.stringify(envelope);
170
+ const inserted = db
171
+ .prepare(
172
+ "INSERT OR IGNORE INTO mailbox(id,envelope,bytes,ask,receivedAt,notified) VALUES(?,?,?,?,?,?)",
173
+ )
174
+ .run(
175
+ envelope.message.id,
176
+ payload,
177
+ Buffer.byteLength(payload),
178
+ envelope.message.expectsReply === true ? 1 : 0,
179
+ Date.now(),
180
+ this.notifications ? 0 : 1,
181
+ );
182
+ this.admissionFailed = false;
183
+ return inserted.changes === 0 ? "duplicate" : "stored";
184
+ } catch {
185
+ this.admissionFailed = true;
186
+ try {
187
+ db.prepare(
188
+ "UPDATE losses SET count=MIN(count+1,9007199254740991) WHERE singleton=1",
189
+ ).run();
190
+ } catch {
191
+ this.failure = new Fault(
192
+ 507,
193
+ "mailbox_storage_failed",
194
+ "Mailbox admission and loss recording failed; loss count is not durable.",
195
+ { lossRecorded: false },
196
+ );
197
+ throw this.failure;
198
+ }
199
+ return "lost";
200
+ }
201
+ });
202
+ }
203
+ private addVolatile(envelope: Envelope): "stored" | "duplicate" | "lost" {
204
+ if (this.entries.has(envelope.message.id)) {
205
+ return "duplicate";
206
+ }
207
+ const bytes = Buffer.byteLength(JSON.stringify(envelope));
208
+ if (this.entries.size >= INBOX_COUNT || this.bytes + bytes > INBOX_BYTES) {
209
+ return "lost";
210
+ }
211
+ this.entries.set(envelope.message.id, {
212
+ ...envelope,
213
+ bytes,
214
+ sequence: ++this.sequence,
215
+ receivedAt: Date.now(),
216
+ acked: 0,
217
+ });
218
+ this.bytes += bytes;
219
+ return "stored";
220
+ }
221
+ get(id: string): Envelope | undefined {
222
+ if (!this.db) {
223
+ return this.entries.get(id);
224
+ }
225
+ const db = this.db;
226
+ return this.stored(() => {
227
+ const row = db.prepare("SELECT envelope FROM mailbox WHERE id=? AND acked=0").get(id);
228
+ return row ? decode(row.envelope) : undefined;
229
+ });
230
+ }
231
+ remove(id: string): void {
232
+ if (this.db) {
233
+ const db = this.db;
234
+ this.stored(() =>
235
+ db
236
+ .prepare(
237
+ "UPDATE mailbox SET acked=1, envelope=CASE WHEN notified=1 THEN NULL ELSE envelope END, bytes=CASE WHEN notified=1 THEN 0 ELSE bytes END WHERE id=?",
238
+ )
239
+ .run(id),
240
+ );
241
+ return;
242
+ }
243
+ const entry = this.entries.get(id);
244
+ if (entry) {
245
+ this.bytes -= entry.bytes;
246
+ this.entries.delete(id);
247
+ }
248
+ }
249
+ ack(ids: readonly string[]): UnknownRecord {
250
+ const acked: string[] = [];
251
+ const retained: string[] = [];
252
+ for (const id of ids) {
253
+ const entry = this.get(id);
254
+ if (entry?.message.expectsReply === true) {
255
+ // Durable consumer intake owns the primary wake; keep only the reply obligation here.
256
+ this.notificationAccepted(id);
257
+ retained.push(id);
258
+ } else if (entry) {
259
+ this.remove(id);
260
+ acked.push(id);
261
+ }
262
+ }
263
+ return { acked, retained };
264
+ }
265
+ page(after: number, limit: number): InboxPage {
266
+ return this.readPage(after, limit, false);
267
+ }
268
+ pendingNotifications(after = 0, limit = INBOX_COUNT): NotificationPage {
269
+ return this.readPage(after, limit, true);
270
+ }
271
+ notificationAccepted(id: string): void {
272
+ const db = this.db;
273
+ if (db) {
274
+ this.stored(() =>
275
+ db
276
+ .prepare(
277
+ "UPDATE mailbox SET notified=1, envelope=CASE WHEN acked=1 THEN NULL ELSE envelope END, bytes=CASE WHEN acked=1 THEN 0 ELSE bytes END WHERE id=?",
278
+ )
279
+ .run(id),
280
+ );
281
+ }
282
+ }
283
+ private readPage(after: number, limit: number, notifications: true): NotificationPage;
284
+ private readPage(after: number, limit: number, notifications: false): InboxPage;
285
+ private readPage(after: number, limit: number, notifications: boolean): InboxPage {
286
+ if (
287
+ !Number.isSafeInteger(after) ||
288
+ after < 0 ||
289
+ !Number.isInteger(limit) ||
290
+ limit < 1 ||
291
+ limit > INBOX_COUNT
292
+ ) {
293
+ throw new Fault(400, "invalid_input", "Invalid mailbox page bounds.");
294
+ }
295
+ return this.stored(() => {
296
+ const candidates = this.candidates(after, limit, notifications);
297
+ const messages: (Envelope & {
298
+ readonly receivedAt?: number;
299
+ readonly acknowledged?: boolean;
300
+ })[] = [];
301
+ let bytes = 0;
302
+ let cursor = after;
303
+ let hasMore = false;
304
+ for (const candidate of candidates) {
305
+ const row = indexRow(candidate);
306
+ if (messages.length === limit || (messages.length > 0 && bytes + row.bytes > INBOX_BYTES)) {
307
+ hasMore = true;
308
+ break;
309
+ }
310
+ const envelope = decode(
311
+ isRecord(candidate) && typeof candidate.envelope === "string"
312
+ ? candidate.envelope
313
+ : JSON.stringify(candidate),
314
+ );
315
+ messages.push(
316
+ notifications
317
+ ? { ...envelope, receivedAt: row.receivedAt, acknowledged: row.acked === 1 }
318
+ : envelope,
319
+ );
320
+ bytes += row.bytes;
321
+ cursor = row.sequence;
322
+ }
323
+ return { messages, nextCursor: String(cursor), hasMore };
324
+ });
325
+ }
326
+ private candidates(after: number, limit: number, notifications: boolean) {
327
+ if (this.db) {
328
+ return this.db
329
+ .prepare(
330
+ `SELECT sequence,bytes,receivedAt,acked,envelope FROM mailbox WHERE ${notifications ? "notified=0" : "acked=0"} AND sequence>? ORDER BY sequence LIMIT ?`,
331
+ )
332
+ .iterate(after, limit + 1);
333
+ }
334
+ return notifications
335
+ ? []
336
+ : [...this.entries.values()].filter((entry) => entry.sequence > after).slice(0, limit + 1);
337
+ }
338
+ status(volatileLosses: number): UnknownRecord {
339
+ const db = this.db;
340
+ const lostMessages = db
341
+ ? this.stored(() => db.prepare("SELECT count FROM losses WHERE singleton=1").get()?.count)
342
+ : volatileLosses;
343
+ if (typeof lostMessages !== "number") {
344
+ throw new Fault(507, "mailbox_storage_failed", "Stored loss counter is invalid.");
345
+ }
346
+ return {
347
+ lostMessages,
348
+ overflow: lostMessages > 0,
349
+ storageError: this.persistent && lostMessages > 0,
350
+ };
351
+ }
352
+ presence(): { readonly pendingAsks: number; readonly acceptsAsks: boolean } {
353
+ const db = this.db;
354
+ if (!db) {
355
+ return {
356
+ pendingAsks: [...this.entries.values()].filter(
357
+ (entry) => entry.message.expectsReply === true,
358
+ ).length,
359
+ acceptsAsks: this.entries.size < INBOX_COUNT && this.bytes < INBOX_BYTES,
360
+ };
361
+ }
362
+ return this.stored(() => {
363
+ const pendingAsks = db
364
+ .prepare("SELECT COUNT(*) AS count FROM mailbox WHERE ask=1 AND acked=0")
365
+ .get()?.count;
366
+ if (typeof pendingAsks !== "number") {
367
+ throw new Fault(507, "mailbox_storage_failed", "Stored ask counter is invalid.");
368
+ }
369
+ return { pendingAsks, acceptsAsks: !this.admissionFailed };
370
+ });
371
+ }
372
+ close(): number {
373
+ this.db?.close();
374
+ const discarded = this.entries.size;
375
+ this.entries.clear();
376
+ this.bytes = 0;
377
+ return discarded;
378
+ }
379
+ }
380
+
381
+ function indexRow(row: unknown) {
382
+ if (!isRecord(row)) {
383
+ throw new Fault(507, "mailbox_storage_failed", "Stored mailbox index is invalid.");
384
+ }
385
+ const { sequence, bytes, receivedAt, acked } = row;
386
+ if (
387
+ typeof sequence !== "number" ||
388
+ typeof bytes !== "number" ||
389
+ typeof receivedAt !== "number" ||
390
+ typeof acked !== "number"
391
+ ) {
392
+ throw new Fault(507, "mailbox_storage_failed", "Stored mailbox index is invalid.");
393
+ }
394
+ return { sequence, bytes, receivedAt, acked };
395
+ }
@@ -0,0 +1,128 @@
1
+ import { join } from "node:path";
2
+ import { isRecord } from "../shared/unknown.ts";
3
+ import type { Identity } from "./bridge-config.ts";
4
+ import { readBridgeFile, writeBridgeJson } from "./bridge-files.ts";
5
+ import { log } from "./bridge-protocol.ts";
6
+
7
+ interface Registration {
8
+ readonly fingerprint: string;
9
+ readonly name: string;
10
+ readonly cwd: string;
11
+ readonly expires: number;
12
+ }
13
+
14
+ interface PresenceOwner {
15
+ readonly stateDir: string;
16
+ readonly clients: () => ReadonlyMap<string, Identity>;
17
+ readonly connect: (identity: Identity, expires: number) => Promise<void>;
18
+ }
19
+
20
+ export class PersistentPresence {
21
+ private readonly registrations = new Map<string, Registration>();
22
+ private stopped = false;
23
+ private timer?: NodeJS.Timeout;
24
+ private running?: Promise<void>;
25
+ private readonly owner: PresenceOwner;
26
+
27
+ constructor(owner: PresenceOwner) {
28
+ this.owner = owner;
29
+ for (const identity of owner.clients().values()) {
30
+ this.restore(identity);
31
+ }
32
+ }
33
+
34
+ private path(fingerprint: string): string {
35
+ return join(this.owner.stateDir, `presence-${fingerprint}.json`);
36
+ }
37
+
38
+ private restore(identity: Identity): void {
39
+ try {
40
+ const value: unknown = JSON.parse(
41
+ readBridgeFile(this.path(identity.fingerprint256), true, 8192).toString("utf8"),
42
+ );
43
+ if (
44
+ isRecord(value) &&
45
+ value.fingerprint === identity.fingerprint256 &&
46
+ value.name === identity.name &&
47
+ value.cwd === identity.cwd &&
48
+ typeof value.expires === "number" &&
49
+ Number.isFinite(value.expires) &&
50
+ value.expires > Date.now()
51
+ ) {
52
+ this.registrations.set(identity.fingerprint256, {
53
+ fingerprint: identity.fingerprint256,
54
+ name: identity.name,
55
+ cwd: identity.cwd,
56
+ expires: value.expires,
57
+ });
58
+ }
59
+ } catch {
60
+ // An absent/invalid saved registration never grants a certificate identity.
61
+ }
62
+ }
63
+
64
+ authenticated(identity: Identity, expires: number): void {
65
+ const previous = this.registrations.get(identity.fingerprint256);
66
+ if (
67
+ previous?.expires === expires &&
68
+ previous.name === identity.name &&
69
+ previous.cwd === identity.cwd
70
+ ) {
71
+ return;
72
+ }
73
+ const registration = {
74
+ fingerprint: identity.fingerprint256,
75
+ name: identity.name,
76
+ cwd: identity.cwd,
77
+ expires,
78
+ };
79
+ writeBridgeJson(this.path(identity.fingerprint256), registration);
80
+ this.registrations.set(identity.fingerprint256, registration);
81
+ this.start();
82
+ }
83
+
84
+ start(): void {
85
+ if (!this.stopped && !this.running) {
86
+ clearTimeout(this.timer);
87
+ this.timer = setTimeout(() => {
88
+ this.running = this.maintain().finally(() => {
89
+ this.running = undefined;
90
+ if (!this.stopped) {
91
+ this.timer = setTimeout(() => this.start(), 15000);
92
+ }
93
+ });
94
+ }, 0);
95
+ }
96
+ }
97
+
98
+ private async maintain(): Promise<void> {
99
+ for (const registration of this.registrations.values()) {
100
+ if (this.stopped) {
101
+ return;
102
+ }
103
+ const identity = this.owner.clients().get(registration.fingerprint);
104
+ if (
105
+ !identity ||
106
+ identity.name !== registration.name ||
107
+ identity.cwd !== registration.cwd ||
108
+ registration.expires <= Date.now()
109
+ ) {
110
+ this.registrations.delete(registration.fingerprint);
111
+ continue;
112
+ }
113
+ try {
114
+ // Each identity has one connection attempt; preserve the native broker's spawn safeguards.
115
+ // oxlint-disable-next-line no-await-in-loop
116
+ await this.owner.connect(identity, registration.expires);
117
+ } catch {
118
+ log("presence_retry", { name: identity.name, result: "broker_unavailable" });
119
+ }
120
+ }
121
+ }
122
+
123
+ async stop(): Promise<void> {
124
+ this.stopped = true;
125
+ clearTimeout(this.timer);
126
+ await this.running;
127
+ }
128
+ }
@@ -4,7 +4,8 @@ import { ASK_TIMEOUT, Fault, INBOX_COUNT, label, object, text } from "./bridge-p
4
4
  import { body } from "./bridge-http.ts";
5
5
 
6
6
  export type Operation =
7
- | { readonly route: "GET /v1/list" | "GET /v1/inbox" | "POST /v1/register" }
7
+ | { readonly route: "GET /v1/list" | "POST /v1/register" }
8
+ | { readonly route: "GET /v1/inbox"; readonly after: number; readonly limit: number }
8
9
  | { readonly route: "GET /v1/sessions" }
9
10
  | { readonly route: "POST /v1/start"; readonly name: string }
10
11
  | { readonly route: "POST /v1/stop"; readonly sessionId: string }
@@ -46,10 +47,18 @@ export function routeFor(req: IncomingMessage): Route {
46
47
  ) {
47
48
  throw new Fault(403, "browser_forbidden", "Browser access is not supported.");
48
49
  }
49
- const route = `${req.method ?? ""} ${req.url ?? ""}`;
50
+ const route = `${req.method ?? ""} ${(req.url ?? "").split("?")[0]}`;
50
51
  if (!isRoute(route)) {
51
52
  throw new Fault(404, "not_found", "Unknown method or path.");
52
53
  }
54
+ if (req.url?.includes("?") === true && route !== "GET /v1/inbox") {
55
+ throw new Fault(400, "invalid_input", "Only inbox accepts query parameters.");
56
+ }
57
+ checkGetBody(req);
58
+ return route;
59
+ }
60
+
61
+ function checkGetBody(req: IncomingMessage): void {
53
62
  if (
54
63
  req.method === "GET" &&
55
64
  (req.headers["transfer-encoding"] !== undefined ||
@@ -57,7 +66,34 @@ export function routeFor(req: IncomingMessage): Route {
57
66
  ) {
58
67
  throw new Fault(400, "invalid_input", "GET requests must not have a body.");
59
68
  }
60
- return route;
69
+ }
70
+
71
+ function inboxQuery(req: IncomingMessage): Extract<Operation, { readonly route: "GET /v1/inbox" }> {
72
+ const url = req.url ?? "";
73
+ const separator = url.indexOf("?");
74
+ const query = new URLSearchParams(separator === -1 ? "" : url.slice(separator + 1));
75
+ if (
76
+ [...query.keys()].some(
77
+ (key) => !["after", "limit"].includes(key) || query.getAll(key).length !== 1,
78
+ )
79
+ ) {
80
+ throw new Fault(400, "invalid_input", "Unknown or repeated inbox query parameter.");
81
+ }
82
+ const after = query.get("after") ?? "0";
83
+ const limit = query.get("limit") ?? String(INBOX_COUNT);
84
+ if (
85
+ !/^(0|[1-9]\d{0,15})$/.test(after) ||
86
+ !Number.isSafeInteger(Number(after)) ||
87
+ !/^[1-9]\d{0,2}$/.test(limit) ||
88
+ Number(limit) > INBOX_COUNT
89
+ ) {
90
+ throw new Fault(
91
+ 400,
92
+ "invalid_input",
93
+ "after must be a nonnegative safe integer cursor; limit must be 1..256.",
94
+ );
95
+ }
96
+ return { route: "GET /v1/inbox", after: Number(after), limit: Number(limit) };
61
97
  }
62
98
 
63
99
  function timeout(value: unknown): number {
@@ -76,10 +112,11 @@ export async function operationFor(
76
112
  const input = req.method === "POST" ? object(await body(req, signal), fields[route]) : {};
77
113
  switch (route) {
78
114
  case "GET /v1/list":
79
- case "GET /v1/inbox":
80
115
  case "POST /v1/register":
81
116
  case "GET /v1/sessions":
82
117
  return { route };
118
+ case "GET /v1/inbox":
119
+ return inboxQuery(req);
83
120
  case "POST /v1/start":
84
121
  return { route, name: label(input.name, "session name", 64).trim() };
85
122
  case "POST /v1/stop":