@fitchmultz/pi-subagents 0.48.0 → 0.49.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## [0.49.0] - 2026-10-14
6
+
7
+ ### Added
8
+
9
+ - Event-driven remote inbox waits through optional `waitMs` (0–10000 ms) and CLI `inbox --wait-ms`, preserving immediate reads by default, durable cursor paging and explicit acknowledgement.
10
+ - Native fallback wake two seconds after mailbox commit without intake acknowledgement, with durable shared admission of at most 20 attempts per sliding hour across all identities and URLs. Pending cold batches coalesce, retain retry deadlines across crashes and rotations, and recover automatically when allowance returns.
11
+ - Stopped-helper audit preflight for preserving known remaining wake allowance during migration; unverified legacy usage conservatively reserves the first hour.
12
+
13
+ ### Fixed
14
+
15
+ - Cancelling an inbox read no longer disconnects the certificate-bound native identity or interrupts unrelated requests. Revocation, expiry, broker disconnect and shutdown still cancel outstanding waits.
16
+
3
17
  ## [0.48.0] - 2026-10-14
4
18
 
5
19
  ### Added
@@ -1 +1 @@
1
- export const EXTENSION_BUILD = Object.freeze({"version":"0.48.0","sha256":"288e6058a52baeafc2529e0a8bc267541c4dbb2e21de11af1ef89058f4809750"});
1
+ export const EXTENSION_BUILD = Object.freeze({"version":"0.49.0","sha256":"6cd61dacb62268abb87c7005b638048a141cd17ad6091e486146bbd5e1d01338"});
@@ -5,7 +5,7 @@ import { parseEnv } from "node:util";
5
5
  import { isUnknownArray } from "../shared/unknown.js";
6
6
  import { BODY_LIMIT, object, label, fingerprint } from "./bridge-protocol.js";
7
7
  import { launchPolicy } from "./bridge-launch-policy.js";
8
- import { stateDirectory } from "./bridge-mailbox.js";
8
+ import { stateDirectory } from "./bridge-storage.js";
9
9
  import { readBridgeFile } from "./bridge-files.js";
10
10
  const MAX_CLIENTS = 64;
11
11
  function wakePolicy(value) {
@@ -1,63 +1,11 @@
1
- import { closeSync, constants, fstatSync, lstatSync, openSync, realpathSync } from "node:fs";
2
- import { isAbsolute, join } from "node:path";
3
- import { DatabaseSync } from "node:sqlite";
1
+ import { privateDatabase } from "./bridge-storage.js";
4
2
  import { isRecord } from "../shared/unknown.js";
5
3
  import { normalizeMessage, normalizeSessionInfo } from "./types.js";
6
- import { Fault, INBOX_BYTES, INBOX_COUNT, fingerprint as certificateFingerprint, label, } from "./bridge-protocol.js";
7
- export function stateDirectory(value) {
8
- if (value === undefined) {
9
- return;
10
- }
11
- const path = label(value, "stateDir", 4096);
12
- const stat = lstatSync(path);
13
- if (!isAbsolute(path) ||
14
- !stat.isDirectory() ||
15
- stat.uid !== process.getuid?.() ||
16
- (stat.mode & 0o777) !== 0o700) {
17
- throw new Error("stateDir must be an absolute owned directory with mode 0700, not a symlink.");
18
- }
19
- return realpathSync(path);
20
- }
21
- function privateFile(path, create = false) {
22
- const flags = create ? constants.O_RDWR | constants.O_CREAT : constants.O_RDONLY;
23
- const fd = openSync(path, flags | constants.O_NOFOLLOW, 0o600);
24
- try {
25
- const stat = fstatSync(fd);
26
- if (!stat.isFile() ||
27
- stat.uid !== process.getuid?.() ||
28
- (stat.mode & 0o777) !== 0o600 ||
29
- stat.nlink !== 1) {
30
- throw new Error("Mailbox files must be owned regular files with mode 0600 and one link.");
31
- }
32
- }
33
- finally {
34
- closeSync(fd);
35
- }
36
- }
4
+ import { Fault, INBOX_BYTES, INBOX_COUNT, fingerprint as certificateFingerprint, } from "./bridge-protocol.js";
37
5
  function database(stateDir, fingerprint) {
38
- stateDirectory(stateDir);
39
- const path = join(stateDir, `${certificateFingerprint(fingerprint)}.sqlite`);
40
- privateFile(path, true);
41
- for (const suffix of ["-journal", "-wal", "-shm"]) {
42
- try {
43
- lstatSync(`${path}${suffix}`);
44
- }
45
- catch (error) {
46
- if (isRecord(error) && error.code === "ENOENT") {
47
- continue;
48
- }
49
- throw error;
50
- }
51
- privateFile(`${path}${suffix}`);
52
- }
53
- const db = new DatabaseSync(path, { enableDoubleQuotedStringLiterals: false });
6
+ const { db } = privateDatabase(stateDir, `${certificateFingerprint(fingerprint)}.sqlite`);
54
7
  try {
55
8
  db.exec(`
56
- PRAGMA journal_mode=DELETE;
57
- PRAGMA synchronous=FULL;
58
- PRAGMA secure_delete=ON;
59
- PRAGMA cache_size=-1024;
60
- PRAGMA busy_timeout=1000;
61
9
  CREATE TABLE IF NOT EXISTS mailbox (
62
10
  sequence INTEGER PRIMARY KEY AUTOINCREMENT,
63
11
  id TEXT NOT NULL UNIQUE,
@@ -10,7 +10,10 @@ export class PersistentPresence {
10
10
  owner;
11
11
  constructor(owner) {
12
12
  this.owner = owner;
13
- for (const identity of owner.clients().values()) {
13
+ this.restoreAll();
14
+ }
15
+ restoreAll() {
16
+ for (const identity of this.owner.clients().values()) {
14
17
  this.restore(identity);
15
18
  }
16
19
  }
@@ -33,6 +36,7 @@ export class PersistentPresence {
33
36
  cwd: identity.cwd,
34
37
  expires: value.expires,
35
38
  });
39
+ this.owner.restored(identity, value.expires);
36
40
  }
37
41
  }
38
42
  catch {
@@ -9,7 +9,7 @@ const fields = {
9
9
  "GET /v1/inbox": [],
10
10
  "POST /v1/register": [],
11
11
  "POST /v1/ack": ["ids"],
12
- "POST /v1/send": ["to", "message"],
12
+ "POST /v1/send": ["to", "message", "passive", "messageId"],
13
13
  "POST /v1/ask": ["to", "message", "timeoutMs"],
14
14
  "POST /v1/reply": ["replyTo", "message"],
15
15
  "POST /v1/start": ["name"],
@@ -60,16 +60,23 @@ function queryFor(req, allowed) {
60
60
  return query;
61
61
  }
62
62
  function inboxQuery(req) {
63
- const query = queryFor(req, ["after", "limit"]);
63
+ const query = queryFor(req, ["after", "limit", "waitMs"]);
64
64
  const after = query.get("after") ?? "0";
65
65
  const limit = query.get("limit") ?? String(INBOX_COUNT);
66
+ const waitMs = query.get("waitMs") ?? "0";
66
67
  if (!/^(0|[1-9]\d{0,15})$/.test(after) ||
67
68
  !Number.isSafeInteger(Number(after)) ||
68
69
  !/^[1-9]\d{0,2}$/.test(limit) ||
69
- Number(limit) > INBOX_COUNT) {
70
- throw new Fault(400, "invalid_input", "after must be a nonnegative safe integer cursor; limit must be 1..256.");
70
+ Number(limit) > INBOX_COUNT ||
71
+ !/^(0|[1-9]\d{0,3}|10000)$/.test(waitMs)) {
72
+ throw new Fault(400, "invalid_input", "after must be a nonnegative safe integer cursor; limit must be 1..256; waitMs must be 0..10000.");
71
73
  }
72
- return { route: "GET /v1/inbox", after: Number(after), limit: Number(limit) };
74
+ return {
75
+ route: "GET /v1/inbox",
76
+ after: Number(after),
77
+ limit: Number(limit),
78
+ waitMs: Number(waitMs),
79
+ };
73
80
  }
74
81
  function pageNumber(value, fallback, min, max) {
75
82
  const number = value === null ? fallback : Number(value);
@@ -183,7 +190,7 @@ function messagingOperation(route, input, req) {
183
190
  }
184
191
  return { route, ids: [...new Set(input.ids.map((id) => label(id, "message ID")))] };
185
192
  case "POST /v1/send":
186
- return { route, message: text(input.message), to: label(input.to, "to").trim() };
193
+ return send(input);
187
194
  case "POST /v1/ask":
188
195
  return {
189
196
  route,
@@ -200,6 +207,18 @@ function messagingOperation(route, input, req) {
200
207
  return nativeControl(route, input);
201
208
  }
202
209
  }
210
+ function send(input) {
211
+ if (input.passive !== undefined && typeof input.passive !== "boolean") {
212
+ throw new Fault(400, "invalid_input", "passive must be a boolean.");
213
+ }
214
+ return {
215
+ route: "POST /v1/send",
216
+ message: text(input.message),
217
+ to: label(input.to, "to").trim(),
218
+ passive: input.passive,
219
+ messageId: input.messageId === undefined ? undefined : label(input.messageId, "message ID"),
220
+ };
221
+ }
203
222
  export async function operationFor(req, route, signal) {
204
223
  const input = await operationInput(req, route, signal);
205
224
  switch (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.js";
3
4
  import { resolveSessionProjectId } from "./session-targets.js";
4
5
  import { Fault, cancelled, log, wait } from "./bridge-protocol.js";
@@ -21,6 +22,7 @@ export class Session {
21
22
  });
22
23
  requests = new Set();
23
24
  inbox;
25
+ inboxChanged = new AbortController();
24
26
  replying = new Set();
25
27
  pending;
26
28
  controls = new ControlReplies();
@@ -48,9 +50,7 @@ export class Session {
48
50
  this.touch();
49
51
  }
50
52
  guard(signal) {
51
- if (signal) {
52
- cancelled(signal);
53
- }
53
+ signal?.throwIfAborted();
54
54
  if (this.closed) {
55
55
  throw this.closeReason;
56
56
  }
@@ -211,12 +211,22 @@ export class Session {
211
211
  this.close(new Fault(507, "mailbox_storage_failed", "Mailbox storage failed; inspect the metadata audit for unrecorded losses."));
212
212
  return;
213
213
  }
214
- if (result === "lost" || (result === "duplicate" && !this.inbox.persistent)) {
215
- this.owner.lost(this.identity.fingerprint256);
214
+ if (result !== "duplicate" || !this.inbox.persistent) {
215
+ if (result !== "stored") {
216
+ this.owner.lost(this.identity.fingerprint256);
217
+ }
218
+ this.inboxChanged.abort();
219
+ this.inboxChanged = new AbortController();
216
220
  }
217
- this.recordReceive(from.id, message.id, result);
221
+ log(result === "lost" ? "inbox_overflow" : "received", {
222
+ ...this.fields(),
223
+ peer: from.id,
224
+ messageId: message.id,
225
+ result: result === "lost" && this.inbox.persistent ? "storage_failed" : result,
226
+ });
218
227
  if (result !== "lost") {
219
228
  this.resolveReply(from, message);
229
+ this.owner.mailboxChanged?.();
220
230
  }
221
231
  this.presence();
222
232
  }
@@ -228,14 +238,6 @@ export class Session {
228
238
  pending.resolve({ from, message });
229
239
  return true;
230
240
  }
231
- recordReceive(peer, messageId, result) {
232
- log(result === "lost" ? "inbox_overflow" : "received", {
233
- ...this.fields(),
234
- peer,
235
- messageId,
236
- result: result === "lost" && this.inbox.persistent ? "storage_failed" : result,
237
- });
238
- }
239
241
  presence() {
240
242
  if (this.closed) {
241
243
  return;
@@ -311,17 +313,6 @@ export class Session {
311
313
  const waiting = new WaitingAsk();
312
314
  this.pending = waiting;
313
315
  const { signal } = context;
314
- const abort = () => {
315
- try {
316
- cancelled(signal);
317
- }
318
- catch (error) {
319
- waiting.reject(error instanceof Error
320
- ? error
321
- : new Fault(499, "client_aborted", "HTTP client disconnected; delivery may be unknown."));
322
- }
323
- };
324
- signal.addEventListener("abort", abort, { once: true });
325
316
  try {
326
317
  await this.connect(signal);
327
318
  const peer = await this.target(operation.to, signal);
@@ -338,16 +329,37 @@ export class Session {
338
329
  }
339
330
  finally {
340
331
  waiting.clear();
341
- signal.removeEventListener("abort", abort);
342
- if (this.pending === waiting) {
343
- this.pending = undefined;
344
- }
332
+ this.pending = undefined;
345
333
  }
346
334
  }
347
- ack(ids) {
348
- const result = this.inbox.ack(ids);
349
- this.presence();
350
- return result;
335
+ async readInbox(operation, context) {
336
+ const read = () => ({
337
+ ...this.inbox.page(operation.after, operation.limit),
338
+ ...this.inbox.status(this.owner.losses(this.identity.fingerprint256)),
339
+ });
340
+ const deadline = performance.now() + operation.waitMs;
341
+ const losses = read().lostMessages;
342
+ for (;;) {
343
+ // Subscribe to the post-commit signal before rereading the authoritative cursor page.
344
+ const signal = AbortSignal.any([this.inboxChanged.signal, context.signal]);
345
+ const page = read();
346
+ const remaining = deadline - performance.now();
347
+ if (page.messages.length > 0 || page.lostMessages !== losses || remaining <= 0) {
348
+ return page;
349
+ }
350
+ log("inbox_wait", { ...this.fields(), requestId: context.requestId, after: operation.after });
351
+ try {
352
+ // A nonmatching commit must rearm the same cursor, not start parallel readers.
353
+ // oxlint-disable-next-line no-await-in-loop
354
+ await delay(remaining, undefined, { signal });
355
+ }
356
+ catch (error) {
357
+ if (!signal.aborted) {
358
+ throw error;
359
+ }
360
+ }
361
+ this.guard(context.signal);
362
+ }
351
363
  }
352
364
  async answer(replyTo, message, context) {
353
365
  const entry = this.inbox.get(replyTo);
@@ -371,6 +383,7 @@ export class Session {
371
383
  }
372
384
  const data = { ...(await this.send(peer, message, context, { replyTo })) };
373
385
  this.inbox.remove(replyTo);
386
+ this.owner.mailboxChanged?.();
374
387
  this.presence();
375
388
  return data;
376
389
  }
@@ -389,28 +402,30 @@ export class Session {
389
402
  await this.connect(context.signal);
390
403
  }
391
404
  if (operation.route === "POST /v1/ack") {
392
- return this.ack(operation.ids);
405
+ const result = this.inbox.ack(operation.ids);
406
+ this.owner.mailboxChanged?.();
407
+ this.presence();
408
+ return result;
393
409
  }
394
- return {
395
- ...this.inbox.page(operation.after, operation.limit),
396
- ...this.inbox.status(this.owner.losses(this.identity.fingerprint256)),
397
- };
410
+ return this.readInbox(operation, context);
398
411
  }
399
412
  await this.connect(context.signal);
400
413
  switch (operation.route) {
401
414
  case "POST /v1/register":
402
415
  return { sessionId: this.client.sessionId, name: this.identity.name };
403
- case "GET /v1/list": {
404
- const sessions = await this.peers(context.signal);
416
+ case "GET /v1/list":
405
417
  return {
406
418
  sessionId: this.client.sessionId,
407
- sessions: bridgeSessionList(sessions),
419
+ sessions: bridgeSessionList(await this.peers(context.signal)),
408
420
  };
421
+ case "POST /v1/send": {
422
+ const peer = await this.target(operation.to, context.signal);
423
+ const receipt = await this.send(peer.id, operation.message, context, {
424
+ messageId: operation.messageId,
425
+ delivery: operation.passive === true ? "passive" : "steer",
426
+ });
427
+ return { ...receipt };
409
428
  }
410
- case "POST /v1/send":
411
- return {
412
- ...(await this.send((await this.target(operation.to, context.signal)).id, operation.message, context)),
413
- };
414
429
  case "POST /v1/reply":
415
430
  return this.answer(operation.replyTo, operation.message, context);
416
431
  }
@@ -0,0 +1,73 @@
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.js";
5
+ import { label } from "./bridge-protocol.js";
6
+ export function stateDirectory(value) {
7
+ if (value === undefined) {
8
+ return;
9
+ }
10
+ const path = label(value, "stateDir", 4096);
11
+ const stat = lstatSync(path);
12
+ if (!isAbsolute(path) ||
13
+ !stat.isDirectory() ||
14
+ stat.uid !== process.getuid?.() ||
15
+ (stat.mode & 0o777) !== 0o700) {
16
+ throw new Error("stateDir must be an absolute owned directory with mode 0700, not a symlink.");
17
+ }
18
+ return realpathSync(path);
19
+ }
20
+ export function privateFile(path) {
21
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
22
+ try {
23
+ const stat = fstatSync(fd);
24
+ if (!stat.isFile() ||
25
+ stat.uid !== process.getuid?.() ||
26
+ (stat.mode & 0o777) !== 0o600 ||
27
+ stat.nlink !== 1) {
28
+ throw new Error("Storage files must be owned regular files with mode 0600 and one link.");
29
+ }
30
+ }
31
+ finally {
32
+ closeSync(fd);
33
+ }
34
+ }
35
+ /** Native SQLite FULL commits own durability; never replace an unreadable existing database. */
36
+ export function privateDatabase(stateDir, name) {
37
+ stateDirectory(stateDir);
38
+ const path = join(stateDir, name);
39
+ let created = false;
40
+ try {
41
+ const fd = openSync(path, constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
42
+ closeSync(fd);
43
+ created = true;
44
+ }
45
+ catch (error) {
46
+ if (!isRecord(error) || error.code !== "EEXIST") {
47
+ throw error;
48
+ }
49
+ }
50
+ privateFile(path);
51
+ for (const suffix of ["-journal", "-wal", "-shm"]) {
52
+ try {
53
+ lstatSync(`${path}${suffix}`);
54
+ }
55
+ catch (error) {
56
+ if (isRecord(error) && error.code === "ENOENT") {
57
+ continue;
58
+ }
59
+ throw error;
60
+ }
61
+ privateFile(`${path}${suffix}`);
62
+ }
63
+ const db = new DatabaseSync(path, { enableDoubleQuotedStringLiterals: false });
64
+ try {
65
+ db.exec(`PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; PRAGMA secure_delete=ON;
66
+ PRAGMA cache_size=-1024; PRAGMA busy_timeout=1000;`);
67
+ return { db, created };
68
+ }
69
+ catch (error) {
70
+ db.close();
71
+ throw error;
72
+ }
73
+ }
@@ -0,0 +1,57 @@
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.js";
6
+ import { isRecord } from "../shared/unknown.js";
7
+ export const WAKE_OWNER_FILE = /^wake-owner-[0-9a-f-]{36}\.lock$/;
8
+ /** SQLite's OS lock survives pauses and is released on exit, without PID reuse or stale leases. */
9
+ export class WakeOwner {
10
+ name = `wake-owner-${randomUUID()}.lock`;
11
+ db;
12
+ path;
13
+ constructor(stateDir) {
14
+ this.path = join(stateDir, this.name);
15
+ this.db = privateDatabase(stateDir, this.name).db;
16
+ try {
17
+ this.db.exec("BEGIN EXCLUSIVE");
18
+ }
19
+ catch (error) {
20
+ this.db.close();
21
+ throw error;
22
+ }
23
+ }
24
+ close() {
25
+ this.db.close();
26
+ rmSync(this.path, { force: true });
27
+ }
28
+ }
29
+ export function stoppedWakeOwner(stateDir, name) {
30
+ if (!WAKE_OWNER_FILE.test(name)) {
31
+ throw new Error("Invalid wake owner.");
32
+ }
33
+ const path = join(stateDir, name);
34
+ try {
35
+ privateFile(path);
36
+ }
37
+ catch (error) {
38
+ if (isRecord(error) && error.code === "ENOENT") {
39
+ return true;
40
+ }
41
+ throw error;
42
+ }
43
+ const db = new DatabaseSync(path);
44
+ try {
45
+ db.exec("PRAGMA busy_timeout=0; BEGIN EXCLUSIVE");
46
+ return true;
47
+ }
48
+ catch (error) {
49
+ if (isRecord(error) && error.errcode === 5) {
50
+ return false;
51
+ }
52
+ throw error;
53
+ }
54
+ finally {
55
+ db.close();
56
+ }
57
+ }