@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.
@@ -9,9 +9,20 @@ import { transcriptOptions, type TranscriptOptions } from "./transcript.ts";
9
9
 
10
10
  export type MessagingOperation =
11
11
  | { readonly route: "GET /v1/list" | "POST /v1/register" }
12
- | { readonly route: "GET /v1/inbox"; readonly after: number; readonly limit: number }
12
+ | {
13
+ readonly route: "GET /v1/inbox";
14
+ readonly after: number;
15
+ readonly limit: number;
16
+ readonly waitMs: number;
17
+ }
13
18
  | { readonly route: "POST /v1/ack"; readonly ids: readonly string[] }
14
- | { readonly route: "POST /v1/send"; readonly to: string; readonly message: string }
19
+ | {
20
+ readonly route: "POST /v1/send";
21
+ readonly to: string;
22
+ readonly message: string;
23
+ readonly passive?: boolean;
24
+ readonly messageId?: string;
25
+ }
15
26
  | {
16
27
  readonly route: "POST /v1/ask";
17
28
  readonly to: string;
@@ -73,7 +84,7 @@ const fields = {
73
84
  "GET /v1/inbox": [],
74
85
  "POST /v1/register": [],
75
86
  "POST /v1/ack": ["ids"],
76
- "POST /v1/send": ["to", "message"],
87
+ "POST /v1/send": ["to", "message", "passive", "messageId"],
77
88
  "POST /v1/ask": ["to", "message", "timeoutMs"],
78
89
  "POST /v1/reply": ["replyTo", "message"],
79
90
  "POST /v1/start": ["name"],
@@ -134,22 +145,29 @@ function queryFor(req: IncomingMessage, allowed: readonly string[]): URLSearchPa
134
145
  return query;
135
146
  }
136
147
  function inboxQuery(req: IncomingMessage): Extract<Operation, { readonly route: "GET /v1/inbox" }> {
137
- const query = queryFor(req, ["after", "limit"]);
148
+ const query = queryFor(req, ["after", "limit", "waitMs"]);
138
149
  const after = query.get("after") ?? "0";
139
150
  const limit = query.get("limit") ?? String(INBOX_COUNT);
151
+ const waitMs = query.get("waitMs") ?? "0";
140
152
  if (
141
153
  !/^(0|[1-9]\d{0,15})$/.test(after) ||
142
154
  !Number.isSafeInteger(Number(after)) ||
143
155
  !/^[1-9]\d{0,2}$/.test(limit) ||
144
- Number(limit) > INBOX_COUNT
156
+ Number(limit) > INBOX_COUNT ||
157
+ !/^(0|[1-9]\d{0,3}|10000)$/.test(waitMs)
145
158
  ) {
146
159
  throw new Fault(
147
160
  400,
148
161
  "invalid_input",
149
- "after must be a nonnegative safe integer cursor; limit must be 1..256.",
162
+ "after must be a nonnegative safe integer cursor; limit must be 1..256; waitMs must be 0..10000.",
150
163
  );
151
164
  }
152
- return { route: "GET /v1/inbox", after: Number(after), limit: Number(limit) };
165
+ return {
166
+ route: "GET /v1/inbox",
167
+ after: Number(after),
168
+ limit: Number(limit),
169
+ waitMs: Number(waitMs),
170
+ };
153
171
  }
154
172
  function pageNumber(value: string | null, fallback: number, min: number, max: number): number {
155
173
  const number = value === null ? fallback : Number(value);
@@ -288,7 +306,7 @@ function messagingOperation(
288
306
  }
289
307
  return { route, ids: [...new Set(input.ids.map((id) => label(id, "message ID")))] };
290
308
  case "POST /v1/send":
291
- return { route, message: text(input.message), to: label(input.to, "to").trim() };
309
+ return send(input);
292
310
  case "POST /v1/ask":
293
311
  return {
294
312
  route,
@@ -305,6 +323,18 @@ function messagingOperation(
305
323
  return nativeControl(route, input);
306
324
  }
307
325
  }
326
+ function send(input: Readonly<Record<string, unknown>>): MessagingOperation {
327
+ if (input.passive !== undefined && typeof input.passive !== "boolean") {
328
+ throw new Fault(400, "invalid_input", "passive must be a boolean.");
329
+ }
330
+ return {
331
+ route: "POST /v1/send",
332
+ message: text(input.message),
333
+ to: label(input.to, "to").trim(),
334
+ passive: input.passive,
335
+ messageId: input.messageId === undefined ? undefined : label(input.messageId, "message ID"),
336
+ };
337
+ }
308
338
  export async function operationFor(
309
339
  req: IncomingMessage,
310
340
  route: Route,
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { setTimeout as delay } from "node:timers/promises";
2
3
  import { IntercomClient } from "./broker/client.ts";
3
4
  import { resolveSessionProjectId } from "./session-targets.ts";
4
5
  import type { Message, SendResult, SessionInfo } from "./types.ts";
@@ -18,14 +19,13 @@ const IDLE_TIMEOUT = 5 * 60 * 1000;
18
19
  const SEND_TIMEOUT = 8000;
19
20
  const LIST_TIMEOUT = 5000;
20
21
 
21
- interface SendOptions {
22
+ interface SendOptions extends Pick<Message, "replyTo" | "expectsReply" | "delivery"> {
22
23
  readonly messageId?: string;
23
- readonly replyTo?: string;
24
- readonly expectsReply?: boolean;
25
24
  }
26
25
  export interface SessionOwner {
27
26
  readonly stateDir?: string;
28
27
  readonly notifications?: boolean;
28
+ readonly mailboxChanged?: () => void;
29
29
  readonly allowed: (identity: Identity) => boolean;
30
30
  readonly lost: (fp: string, count?: number) => void;
31
31
  readonly retired: (fp: string, generation: string) => void;
@@ -43,6 +43,7 @@ export class Session {
43
43
  });
44
44
  private readonly requests = new Set<AbortController>();
45
45
  private readonly inbox: Mailbox;
46
+ private inboxChanged = new AbortController();
46
47
  private readonly replying = new Set<string>();
47
48
  private pending?: WaitingAsk;
48
49
  private readonly controls = new ControlReplies();
@@ -76,9 +77,7 @@ export class Session {
76
77
  }
77
78
 
78
79
  guard(signal?: AbortSignal): void {
79
- if (signal) {
80
- cancelled(signal);
81
- }
80
+ signal?.throwIfAborted();
82
81
  if (this.closed) {
83
82
  throw this.closeReason;
84
83
  }
@@ -267,12 +266,22 @@ export class Session {
267
266
  );
268
267
  return;
269
268
  }
270
- if (result === "lost" || (result === "duplicate" && !this.inbox.persistent)) {
271
- this.owner.lost(this.identity.fingerprint256);
269
+ if (result !== "duplicate" || !this.inbox.persistent) {
270
+ if (result !== "stored") {
271
+ this.owner.lost(this.identity.fingerprint256);
272
+ }
273
+ this.inboxChanged.abort();
274
+ this.inboxChanged = new AbortController();
272
275
  }
273
- this.recordReceive(from.id, message.id, result);
276
+ log(result === "lost" ? "inbox_overflow" : "received", {
277
+ ...this.fields(),
278
+ peer: from.id,
279
+ messageId: message.id,
280
+ result: result === "lost" && this.inbox.persistent ? "storage_failed" : result,
281
+ });
274
282
  if (result !== "lost") {
275
283
  this.resolveReply(from, message);
284
+ this.owner.mailboxChanged?.();
276
285
  }
277
286
  this.presence();
278
287
  }
@@ -284,18 +293,6 @@ export class Session {
284
293
  pending.resolve({ from, message });
285
294
  return true;
286
295
  }
287
- private recordReceive(
288
- peer: string,
289
- messageId: string,
290
- result: "stored" | "duplicate" | "lost",
291
- ): void {
292
- log(result === "lost" ? "inbox_overflow" : "received", {
293
- ...this.fields(),
294
- peer,
295
- messageId,
296
- result: result === "lost" && this.inbox.persistent ? "storage_failed" : result,
297
- });
298
- }
299
296
  private presence(): void {
300
297
  if (this.closed) {
301
298
  return;
@@ -393,22 +390,6 @@ export class Session {
393
390
  const waiting = new WaitingAsk();
394
391
  this.pending = waiting;
395
392
  const { signal } = context;
396
- const abort = () => {
397
- try {
398
- cancelled(signal);
399
- } catch (error) {
400
- waiting.reject(
401
- error instanceof Error
402
- ? error
403
- : new Fault(
404
- 499,
405
- "client_aborted",
406
- "HTTP client disconnected; delivery may be unknown.",
407
- ),
408
- );
409
- }
410
- };
411
- signal.addEventListener("abort", abort, { once: true });
412
393
  try {
413
394
  await this.connect(signal);
414
395
  const peer = await this.target(operation.to, signal);
@@ -424,16 +405,39 @@ export class Session {
424
405
  return { ...waiting.receipt, replied: true, reply: answer };
425
406
  } finally {
426
407
  waiting.clear();
427
- signal.removeEventListener("abort", abort);
428
- if (this.pending === waiting) {
429
- this.pending = undefined;
430
- }
408
+ this.pending = undefined;
431
409
  }
432
410
  }
433
- private ack(ids: readonly string[]): UnknownRecord {
434
- const result = this.inbox.ack(ids);
435
- this.presence();
436
- return result;
411
+ private async readInbox(
412
+ operation: Extract<Operation, { readonly route: "GET /v1/inbox" }>,
413
+ context: RequestContext,
414
+ ): Promise<UnknownRecord> {
415
+ const read = (): ReturnType<Mailbox["page"]> & UnknownRecord => ({
416
+ ...this.inbox.page(operation.after, operation.limit),
417
+ ...this.inbox.status(this.owner.losses(this.identity.fingerprint256)),
418
+ });
419
+ const deadline = performance.now() + operation.waitMs;
420
+ const losses = read().lostMessages;
421
+ for (;;) {
422
+ // Subscribe to the post-commit signal before rereading the authoritative cursor page.
423
+ const signal = AbortSignal.any([this.inboxChanged.signal, context.signal]);
424
+ const page = read();
425
+ const remaining = deadline - performance.now();
426
+ if (page.messages.length > 0 || page.lostMessages !== losses || remaining <= 0) {
427
+ return page;
428
+ }
429
+ log("inbox_wait", { ...this.fields(), requestId: context.requestId, after: operation.after });
430
+ try {
431
+ // A nonmatching commit must rearm the same cursor, not start parallel readers.
432
+ // oxlint-disable-next-line no-await-in-loop
433
+ await delay(remaining, undefined, { signal });
434
+ } catch (error) {
435
+ if (!signal.aborted) {
436
+ throw error;
437
+ }
438
+ }
439
+ this.guard(context.signal);
440
+ }
437
441
  }
438
442
  private async answer(
439
443
  replyTo: string,
@@ -461,6 +465,7 @@ export class Session {
461
465
  }
462
466
  const data = { ...(await this.send(peer, message, context, { replyTo })) };
463
467
  this.inbox.remove(replyTo);
468
+ this.owner.mailboxChanged?.();
464
469
  this.presence();
465
470
  return data;
466
471
  } finally {
@@ -478,32 +483,30 @@ export class Session {
478
483
  await this.connect(context.signal);
479
484
  }
480
485
  if (operation.route === "POST /v1/ack") {
481
- return this.ack(operation.ids);
486
+ const result = this.inbox.ack(operation.ids);
487
+ this.owner.mailboxChanged?.();
488
+ this.presence();
489
+ return result;
482
490
  }
483
- return {
484
- ...this.inbox.page(operation.after, operation.limit),
485
- ...this.inbox.status(this.owner.losses(this.identity.fingerprint256)),
486
- };
491
+ return this.readInbox(operation, context);
487
492
  }
488
493
  await this.connect(context.signal);
489
494
  switch (operation.route) {
490
495
  case "POST /v1/register":
491
496
  return { sessionId: this.client.sessionId, name: this.identity.name };
492
- case "GET /v1/list": {
493
- const sessions = await this.peers(context.signal);
497
+ case "GET /v1/list":
494
498
  return {
495
499
  sessionId: this.client.sessionId,
496
- sessions: bridgeSessionList(sessions),
500
+ sessions: bridgeSessionList(await this.peers(context.signal)),
497
501
  };
502
+ case "POST /v1/send": {
503
+ const peer = await this.target(operation.to, context.signal);
504
+ const receipt = await this.send(peer.id, operation.message, context, {
505
+ messageId: operation.messageId,
506
+ delivery: operation.passive === true ? "passive" : "steer",
507
+ });
508
+ return { ...receipt };
498
509
  }
499
- case "POST /v1/send":
500
- return {
501
- ...(await this.send(
502
- (await this.target(operation.to, context.signal)).id,
503
- operation.message,
504
- context,
505
- )),
506
- };
507
510
  case "POST /v1/reply":
508
511
  return this.answer(operation.replyTo, operation.message, context);
509
512
  }
@@ -0,0 +1,83 @@
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 } from "../shared/unknown.ts";
5
+ import { label } from "./bridge-protocol.ts";
6
+
7
+ export function stateDirectory(value: unknown): string | undefined {
8
+ if (value === undefined) {
9
+ return;
10
+ }
11
+ const path = label(value, "stateDir", 4096);
12
+ const stat = lstatSync(path);
13
+ if (
14
+ !isAbsolute(path) ||
15
+ !stat.isDirectory() ||
16
+ stat.uid !== process.getuid?.() ||
17
+ (stat.mode & 0o777) !== 0o700
18
+ ) {
19
+ throw new Error("stateDir must be an absolute owned directory with mode 0700, not a symlink.");
20
+ }
21
+ return realpathSync(path);
22
+ }
23
+
24
+ export function privateFile(path: string): void {
25
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
26
+ try {
27
+ const stat = fstatSync(fd);
28
+ if (
29
+ !stat.isFile() ||
30
+ stat.uid !== process.getuid?.() ||
31
+ (stat.mode & 0o777) !== 0o600 ||
32
+ stat.nlink !== 1
33
+ ) {
34
+ throw new Error("Storage files must be owned regular files with mode 0600 and one link.");
35
+ }
36
+ } finally {
37
+ closeSync(fd);
38
+ }
39
+ }
40
+
41
+ /** Native SQLite FULL commits own durability; never replace an unreadable existing database. */
42
+ export function privateDatabase(
43
+ stateDir: string,
44
+ name: string,
45
+ ): { readonly db: DatabaseSync; readonly created: boolean } {
46
+ stateDirectory(stateDir);
47
+ const path = join(stateDir, name);
48
+ let created = false;
49
+ try {
50
+ const fd = openSync(
51
+ path,
52
+ constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
53
+ 0o600,
54
+ );
55
+ closeSync(fd);
56
+ created = true;
57
+ } catch (error) {
58
+ if (!isRecord(error) || error.code !== "EEXIST") {
59
+ throw error;
60
+ }
61
+ }
62
+ privateFile(path);
63
+ for (const suffix of ["-journal", "-wal", "-shm"]) {
64
+ try {
65
+ lstatSync(`${path}${suffix}`);
66
+ } catch (error) {
67
+ if (isRecord(error) && error.code === "ENOENT") {
68
+ continue;
69
+ }
70
+ throw error;
71
+ }
72
+ privateFile(`${path}${suffix}`);
73
+ }
74
+ const db = new DatabaseSync(path, { enableDoubleQuotedStringLiterals: false });
75
+ try {
76
+ db.exec(`PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; PRAGMA secure_delete=ON;
77
+ PRAGMA cache_size=-1024; PRAGMA busy_timeout=1000;`);
78
+ return { db, created };
79
+ } catch (error) {
80
+ db.close();
81
+ throw error;
82
+ }
83
+ }
@@ -0,0 +1,58 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { DatabaseSync } from "node:sqlite";
5
+ import { privateDatabase, privateFile } from "./bridge-storage.ts";
6
+ import { isRecord } from "../shared/unknown.ts";
7
+
8
+ export const WAKE_OWNER_FILE = /^wake-owner-[0-9a-f-]{36}\.lock$/;
9
+
10
+ /** SQLite's OS lock survives pauses and is released on exit, without PID reuse or stale leases. */
11
+ export class WakeOwner {
12
+ readonly name = `wake-owner-${randomUUID()}.lock`;
13
+ private readonly db: DatabaseSync;
14
+ private readonly path: string;
15
+
16
+ constructor(stateDir: string) {
17
+ this.path = join(stateDir, this.name);
18
+ this.db = privateDatabase(stateDir, this.name).db;
19
+ try {
20
+ this.db.exec("BEGIN EXCLUSIVE");
21
+ } catch (error) {
22
+ this.db.close();
23
+ throw error;
24
+ }
25
+ }
26
+
27
+ close(): void {
28
+ this.db.close();
29
+ rmSync(this.path, { force: true });
30
+ }
31
+ }
32
+
33
+ export function stoppedWakeOwner(stateDir: string, name: string): boolean {
34
+ if (!WAKE_OWNER_FILE.test(name)) {
35
+ throw new Error("Invalid wake owner.");
36
+ }
37
+ const path = join(stateDir, name);
38
+ try {
39
+ privateFile(path);
40
+ } catch (error) {
41
+ if (isRecord(error) && error.code === "ENOENT") {
42
+ return true;
43
+ }
44
+ throw error;
45
+ }
46
+ const db = new DatabaseSync(path);
47
+ try {
48
+ db.exec("PRAGMA busy_timeout=0; BEGIN EXCLUSIVE");
49
+ return true;
50
+ } catch (error) {
51
+ if (isRecord(error) && error.errcode === 5) {
52
+ return false;
53
+ }
54
+ throw error;
55
+ } finally {
56
+ db.close();
57
+ }
58
+ }