@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.
@@ -50,6 +50,7 @@ class Bridge {
50
50
  this.presence = new PersistentPresence({
51
51
  stateDir: config.stateDir,
52
52
  clients: () => this.config.clients,
53
+ restored: (identity, expires) => this.deliveries.session(identity, expires),
53
54
  connect: async (identity, expires) => {
54
55
  await spawnBrokerIfNeeded(process.execPath, []);
55
56
  if (!this.allowed(identity)) {
@@ -132,6 +133,7 @@ class Bridge {
132
133
  session = new Session({
133
134
  stateDir: this.config.stateDir,
134
135
  notifications: identity.wake !== undefined,
136
+ mailboxChanged: () => this.deliveries.changed(),
135
137
  allowed: (candidate) => this.allowed(candidate),
136
138
  lost: (fp, count) => this.lost(fp, count),
137
139
  losses: (fp) => this.losses.get(fp) ?? 0,
@@ -267,6 +269,9 @@ class Bridge {
267
269
  this.losses.delete(fp);
268
270
  }
269
271
  }
272
+ // Retire stale sessions before publishing replacement delivery membership for the same pin.
273
+ this.presence?.restoreAll();
274
+ this.deliveries.changed();
270
275
  }
271
276
  stop() {
272
277
  this.shutdown().catch(() => {
@@ -325,7 +330,9 @@ class Bridge {
325
330
  let controller;
326
331
  let deadline;
327
332
  let peer;
328
- const abort = () => session?.close(new Fault(499, "client_aborted", "HTTP client disconnected; delivery may be unknown."));
333
+ let readOnlyInbox = false;
334
+ const cancel = (fault) => readOnlyInbox ? controller?.abort(fault) : session?.close(fault);
335
+ const abort = () => cancel(new Fault(499, "client_aborted", "HTTP client disconnected; delivery may be unknown."));
329
336
  const onClose = () => {
330
337
  if (!res.writableFinished) {
331
338
  abort();
@@ -333,11 +340,12 @@ class Bridge {
333
340
  };
334
341
  const setDeadline = (ms) => {
335
342
  clearTimeout(deadline);
336
- deadline = setTimeout(() => session?.close(new Fault(504, "request_timeout", "HTTP operation deadline exceeded; delivery may be unknown.")), Math.max(1, ms - (Date.now() - started)));
343
+ deadline = setTimeout(() => cancel(new Fault(504, "request_timeout", "HTTP operation deadline exceeded; delivery may be unknown.")), Math.max(1, ms - (Date.now() - started)));
337
344
  };
338
345
  try {
339
346
  const { identity, expires, certificate } = this.admission(req);
340
347
  const route = routeFor(req);
348
+ readOnlyInbox = route === "GET /v1/inbox";
341
349
  session = this.session(identity, expires);
342
350
  controller = session.begin();
343
351
  const signal = controller.signal;
@@ -92,22 +92,22 @@ Add `stateDir`, `wake` and `launch` to the generated `config.json`. Keep the fil
92
92
 
93
93
  This example gives the agent full authority over every Mac path. The defaults stay restrictive for other identities.
94
94
 
95
- | Field | Purpose |
96
- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
97
- | `stateDir` | Owned 0700 directory, required for unattended use. Holds one 0600 SQLite mailbox per certificate fingerprint, presence proofs and groups. Symlinks, wrong owner or mode, and hard links are rejected. Change it only with a helper restart. Without it, the helper falls back to a legacy volatile inbox (256 messages, 4 MiB) with no wake or presence restore, and keeps groups in `$PI_CODING_AGENT_DIR/intercom/bridge-state`. |
98
- | `cwd` | The identity's presence and project association, the legacy `start` directory and the default spawn directory. It doesn't limit access. |
99
- | `wake.envFile` | Absolute path to an owned 0600 file defining `GROK_WAKE_WEBHOOK_URL` (HTTPS, without URL credentials or fragment) and `GROK_WAKE_WEBHOOK_KEY`. Requires `stateDir`. Reread on SIGHUP. |
100
- | `launch` | Enables saved-session browsing, spawn and resume. Omit it for a messaging-only identity. |
101
- | `packageRoot` | The installed Pi package for children. The helper runs that package's `bin.pi` with its own physical Node. Use the intended installed Pi, not a development SDK with the same version. |
102
- | `sessionDir` | Owned 0700 directory for new journals, written 0600. |
103
- | `workspaceRoots` | Up to 32 directories. Spawn and resume directories must be inside one. `["/"]` approves every path; the default empty list rejects spawn and resume. |
104
- | `resumeDirs` | Up to 32 owned directories searched, with `sessionDir`, for saved sessions. Every journal path component must be owned and symlink-free. |
105
- | `provider`, `model` | Optional default model; set both or neither. Omit them to use Pi's normal model selection. |
106
- | `extensions` | 1–32 absolute extension paths, including Intercom. With `discoverResources: true`, use the same physical Intercom path that native settings load, so a session never runs two Intercom copies. |
107
- | `discoverResources` | Default `false`. `true` keeps Pi's normal discovery of extensions, skills, prompts and context files. |
108
- | `trustProject` | Default `false`. Chooses native `--approve` or `--no-approve` for the selected directory. |
109
- | `offline` | Default `false`. `true` disables automatic catalog and network activity; requested inference still runs. |
110
- | `maxSessions`, `startupTimeoutMs` | Owned-session cap per identity, 1–16 (default 4; 32 per helper), and readiness deadline, 1–60000 ms (default 15000). |
95
+ | Field | Purpose |
96
+ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
97
+ | `stateDir` | Owned 0700 directory, required for unattended use. Holds one 0600 SQLite mailbox per certificate fingerprint, a helper-wide wake ledger, presence proofs and groups. Symlinks, wrong owner or mode, and hard links are rejected. Change it only with a helper restart. Without it, the helper falls back to a legacy volatile inbox (256 messages, 4 MiB) with no wake or presence restore, and keeps groups in `$PI_CODING_AGENT_DIR/intercom/bridge-state`. |
98
+ | `cwd` | The identity's presence and project association, the legacy `start` directory and the default spawn directory. It doesn't limit access. |
99
+ | `wake.envFile` | Absolute path to an owned 0600 file defining `GROK_WAKE_WEBHOOK_URL` (HTTPS, without URL credentials or fragment) and `GROK_WAKE_WEBHOOK_KEY`. Requires `stateDir`. Reread on SIGHUP. |
100
+ | `launch` | Enables saved-session browsing, spawn and resume. Omit it for a messaging-only identity. |
101
+ | `packageRoot` | The installed Pi package for children. The helper runs that package's `bin.pi` with its own physical Node. Use the intended installed Pi, not a development SDK with the same version. |
102
+ | `sessionDir` | Owned 0700 directory for new journals, written 0600. |
103
+ | `workspaceRoots` | Up to 32 directories. Spawn and resume directories must be inside one. `["/"]` approves every path; the default empty list rejects spawn and resume. |
104
+ | `resumeDirs` | Up to 32 owned directories searched, with `sessionDir`, for saved sessions. Every journal path component must be owned and symlink-free. |
105
+ | `provider`, `model` | Optional default model; set both or neither. Omit them to use Pi's normal model selection. |
106
+ | `extensions` | 1–32 absolute extension paths, including Intercom. With `discoverResources: true`, use the same physical Intercom path that native settings load, so a session never runs two Intercom copies. |
107
+ | `discoverResources` | Default `false`. `true` keeps Pi's normal discovery of extensions, skills, prompts and context files. |
108
+ | `trustProject` | Default `false`. Chooses native `--approve` or `--no-approve` for the selected directory. |
109
+ | `offline` | Default `false`. `true` disables automatic catalog and network activity; requested inference still runs. |
110
+ | `maxSessions`, `startupTimeoutMs` | Owned-session cap per identity, 1–16 (default 4; 32 per helper), and readiness deadline, 1–60000 ms (default 15000). |
111
111
 
112
112
  Children inherit the helper's environment, minus enclosing `PI_SUBAGENT_*` metadata, so set `PATH` in the service. A launch succeeds only when the exact saved session ID, name and file run idle in RPC mode and the session's own Intercom peer is published. That proves startup, not working provider credentials. A Pi dialog that needs a human is cancelled, and the session is stopped with `interaction_required`.
113
113
 
@@ -193,7 +193,7 @@ The first authenticated connection establishes persistent presence (see [Mailbox
193
193
 
194
194
  ### More instances
195
195
 
196
- Run one helper per remote agent instance. Each instance gets its own client name and key, port, `stateDir`, wake webhook, LaunchAgent label, Tailscale Serve port and remote host.
196
+ Prefer one helper per Mac with all remote agents in its `clients` allowlist. Each identity gets its own client name, key and optional wake webhook; all identities and webhook URLs share one 20-attempt/hour ledger. If separate listeners are necessary, share the same owned `stateDir` to retain one SQLite allowance, use disjoint client lists and separate ports/LaunchAgent labels, and retain the common CA. Separate state directories create independent budgets; do not use them to multiply the Mac's allocation.
197
197
 
198
198
  Helpers on the same Mac must share one CA; `init` always creates a new one. Pi sessions on the Mac check every native control (see [Stop and cancel](#stop-and-cancel)) against a single policy file: `PI_INTERCOM_BRIDGE_CONFIG`, else `$PI_CODING_AGENT_DIR/intercom/bridge.json`, else `~/.pi/intercom-bridge/config.json`. That file must name the common CA and list the current pin of every instance, so update it whenever you issue, rotate or revoke any instance's certificate. A different Mac has its own CA and policy file.
199
199
 
@@ -227,10 +227,10 @@ api() { # api METHOD PATH [JSON]
227
227
  | ----------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------- |
228
228
  | `POST /v1/register` | `{}` | `sessionId`, `name`; other routes register automatically |
229
229
  | `GET /v1/list` | — | live peers with unambiguous `target`s |
230
- | `POST /v1/send` | `{to,message}` | `id`, `accepted`, `delivered` |
230
+ | `POST /v1/send` | `{to,message,passive?,messageId?}` | `id`, `accepted`, `delivered` |
231
231
  | `POST /v1/ask` | `{to,message,timeoutMs?}` | send receipt and `reply:{from,message}` |
232
232
  | `POST /v1/reply` | `{replyTo,message}` | send receipt to the ask's saved sender |
233
- | `GET /v1/inbox` | `?after=CURSOR&limit=1..256` | `messages`, `nextCursor`, `hasMore`, `overflow`, `lostMessages`, `storageError` |
233
+ | `GET /v1/inbox` | `?after=CURSOR&limit=1..256&waitMs=0..10000` | `messages`, `nextCursor`, `hasMore`, `overflow`, `lostMessages`, `storageError` |
234
234
  | `POST /v1/ack` | `{ids:[...]}`, at most 256 | `acked`, `retained` |
235
235
  | `POST /v1/broadcast` | `{message,group?}` | `status`, per-target `receipts`, member `errors` |
236
236
  | `GET /v1/groups` | — | this identity's `groups` |
@@ -248,19 +248,27 @@ api() { # api METHOD PATH [JSON]
248
248
 
249
249
  Ask waits up to two minutes (`timeoutMs` 100–120000) for the exact recipient and `replyTo`; each identity has one outgoing ask at a time. A timeout doesn't retract a delivered ask, and a late answer arrives in the inbox. Spawn, resume and transcript lookup have a 75-second helper deadline (90 seconds in the CLI); ask has its timeout plus 15 seconds; other routes have 15 seconds. Live transcript controls keep their separate native response deadline; saved name lookup may scan the full catalog.
250
250
 
251
+ Send defaults to active delivery (`steer`). Only `send` accepts the optional boolean `passive`; `true` maps to native `delivery:"passive"`, and `false` keeps the default. Use `node intercom-remote.mjs send --passive --to PEER --message TEXT` for mail without a model wake. Older helpers reject the unknown `passive` field before sending; the client never retries without it or redirects a failed mutation. Upgrade the receiving helper and consumer before relying on end-to-end no-wake delivery.
252
+
253
+ Only `send` accepts an optional `messageId` (`--id ID` in the CLI): a nonblank string of at most 512 characters with no C0/C1 control characters or DEL. The ID is passed unchanged to the native recipient and returned as the receipt's `id`; omitting it still generates a random UUID. A stable ID does not guarantee deduplication, exactly-once delivery or a single provider run, and does not make retrying an unknown outcome safe. Older helpers reject the unknown field without sending; the client never retries without it.
254
+
251
255
  ### Mailbox, presence and wake
252
256
 
253
257
  **Intake.** Every message addressed to the identity is committed to its SQLite mailbox (full synchronous writes) before the helper surfaces it or completes a waiting ask. Unread mail and unanswered asks survive helper crashes, restarts and broker outages. There is no retention limit, so monitor disk space. If a write fails, the mailbox records `overflow`, `lostMessages` and `storageError`. If the loss itself can't be recorded, requests return 507 `mailbox_storage_failed` and the audit log shows `lossRecorded:false`.
254
258
 
255
259
  **Reading.** `GET /v1/inbox` returns a non-destructive page of at most 256 messages and 4 MiB. Keep reading with `after=nextCursor` while `hasMore` is true; start again from `0` to revisit unacked mail. The cursor is an opaque sequence number. Inbox and ack work while the broker is down.
256
260
 
261
+ `waitMs` is an optional integer from 0 to 10000 (default 0, an immediate read). If the requested cursor page is empty, a positive wait returns as soon as matching mail is committed, a new admission loss is recorded, or the wait expires. Existing backlog returns immediately. Timeout returns a fresh empty page with the same cursor and current loss status; reads and waits never ack or consume mail. Use `node intercom-remote.mjs inbox --after CURSOR --wait-ms 10000`. Cancelling an inbox request cancels only that read, not other requests or the identity's broker connection. Revocation, certificate expiry, broker disconnect and helper shutdown still cancel the Session's outstanding requests. All requests, including waiting reads, share the eight-request cap per identity.
262
+
257
263
  **Acknowledging.** Ack an ordinary message to delete its payload; its ID stays as a tombstone so replays are ignored. Ack an ask to record that you've stored it: it appears in `retained`, stays in the inbox as a reply obligation and never triggers a fallback wake. A successful `reply` retires the ask. Reply goes to the ask's saved sender ID; if that sender is offline, it returns `peer_offline` and keeps the obligation. Ack is idempotent. In persistent mode, an answer returned by `ask` is also kept in the inbox; ack `reply.message.id` once you've consumed it.
258
264
 
259
265
  **Presence.** After the first authenticated connection, the helper writes a private presence proof (identity, `cwd` and certificate expiry) to `stateDir` and keeps the identity registered with the broker, refreshing every 15 seconds. It stays registered without remote polling until the certificate expires, the identity changes or is revoked, or the helper stops. A restarted helper restores presence from that proof without any remote request. A revoked identity is never restored.
260
266
 
261
- **Primary wake.** The remote consumer owns waking its agent. Poll the inbox, for example every 15 seconds; append each message to durable storage and fsync it; only then ack it. A crash before the ack replays the message, so deduplicate by `message.id`.
267
+ **Primary wake.** The remote consumer owns waking its agent. Reissue bounded inbox waits without a polling sleep; append each message to durable storage and fsync it; only then ack it. Advance an in-memory tail cursor only after durable intake, draining all `hasMore` pages immediately. Keep that cursor across waits so already-stored retained asks don't cause a busy loop; restart from `0` after a crash and deduplicate by `message.id`. Keep failed ack obligations for retry even after advancing the tail. Each identity needs its own outstanding wait so an empty inbox cannot delay another identity. A wait timeout or reconnect is not a reason to wake an agent. Intake ACK proves storage, not model completion. This notification path requires a running consumer; cold-consumer fallback remains separate.
268
+
269
+ **Passive mail.** Native `delivery:"passive"` and legacy `passive:true` messages stay readable and ACKable in the mailbox, but never trigger the helper's fallback webhook, including after restart. Suppressing a notification does not acknowledge or consume the message. Consumers must also honor these flags: durably store and expose passive mail without invoking their own wake webhook. Passive backlogs do not prevent active mail on later pages from triggering fallback. Non-ask text beginning with the exact uppercase prefix `ACK`, `DUP-ACK`, `FYI`, `NOTE`, `RECEIPT` or `NOWAKE`, followed by whitespace, `:` or end of text, also retires notification intent only. Explicit `expectsReply:true` requests still wake regardless of these prefixes. Native passive intent always wins; there is no text-length cutoff.
262
270
 
263
- **Fallback wake.** If a message stays unacked for at least 120 seconds, the helper POSTs one wake for the oldest such message to `GROK_WAKE_WEBHOOK_URL` with `Authorization: Bearer <GROK_WAKE_WEBHOOK_KEY>` and this body:
271
+ **Fallback wake.** The first observation after a successful mailbox commit durably schedules a two-second transport/fsync ACK grace. Time blocked on the mailbox's SQLite FULL write does not consume that grace. If actionable mail remains unacked, the helper POSTs one wake for the oldest such message to `GROK_WAKE_WEBHOOK_URL` with `Authorization: Bearer <GROK_WAKE_WEBHOOK_KEY>` and this body:
264
272
 
265
273
  ```json
266
274
  {
@@ -273,7 +281,29 @@ Ask waits up to two minutes (`timeoutMs` 100–120000) for the exact recipient a
273
281
  }
274
282
  ```
275
283
 
276
- `ask_id` is the message ID for asks. A 2xx response is logged as `accepted`, but it can't prove the consumer stored the message, so the wake stays pending: the helper posts the same oldest unacked message again every 5 minutes until the consumer acks it. Acking an ask counts. Deduplicate wakes by `message_id`. Other responses and transport failures retry with doubling backoff up to 5 minutes. Redirects are refused, each request times out after 10 seconds, and the helper sends at most one wake per cycle. Fallback stops while the identity is revoked or its certificate has expired.
284
+ `ask_id` is the message ID for asks. All retained, authorized certificate fingerprints targeting the same wake URL form one cold batch. Later messages join that batch without another POST before its retry deadline. A 2xx response is logged as `accepted`, but cannot prove durable intake: notification and payload remain pending until real inbox ACK (acking an ask counts). Deduplicate wakes by `message_id`.
285
+
286
+ **Durable admission.** `wake-quota.sqlite` reserves every POST attempt before dispatch, including rejection, timeout and unknown outcomes, under SQLite `synchronous=FULL`. Each slot remains occupied until a full hour after the POST settles (response, error or abort), so COMMIT, connection and TLS latency cannot shorten the wire-attempt window. All identities, bots, keys and URLs share at most **20 attempts in a sliding hour** per ledger. Restart, SIGHUP and certificate/URL rotation do not reset it. Uncertainty and backoff are committed with each reservation: retry after 30 seconds, doubling through 60, 120 and 240 seconds to a five-minute ceiling while the batch remains unacknowledged. Real ACK retirement clears only that batch's retry state; fresh work receives the two-second grace immediately when quota remains. Redirects are refused and each request times out after ten seconds. Storage errors fail closed before POST; repair permissions/disk/corruption without deleting or replacing the ledger to reset usage.
287
+
288
+ An open attempt has no expiry while its listener is alive. Private `wake-owner-*.lock` SQLite files hold OS locks for each listener's lifetime, including across process pauses; they are not PID or timed leases. Owner-file creation and lock acquisition are serialized with ledger recovery, so another listener cannot reap an initializing owner. After a listener exits, another listener conservatively counts its unfinished attempts for a full hour from recovery. Failed settlement writes remain counted and are retried before that listener dispatches again. Do not remove these files or any ledger to regain quota. Stop **all** old listeners before upgrading a version-1 ledger: its pre-dispatch timestamps cannot prove POST completion, so migration retains every recorded slot for a full hour from upgrade (or its recorded future time, if later), preserving retry state. Subsequent opens do not restart that migration hold.
289
+
290
+ Mailbox commits/ACKs, startup reconstruction and exact retry/budget deadlines drive fallback. There is no periodic intake scan. Presence maintenance's separate broker lease timer is not inbox polling. Pending mail recovers automatically after budget expiry, failures and helper restart. Revocation or certificate expiry stops that identity's fallback without deleting unread payload.
291
+
292
+ **First upgrade and audit preflight.** Fresh empty installations start with an unused allowance. An installation with mailbox/presence state but no ledger has unknown legacy usage and conservatively reserves all 20 slots for one hour. This is a migration quota exception, not a first-wake latency policy. Native JSONL outcomes have timestamps, but omit crash-before-log attempts and may have `log_overflow` gaps. Do not treat them as a complete pre-dispatch ledger.
293
+
294
+ Where immutable prior source/runtime and owned audit history prove complete coverage, stop every legacy writer (disable automatic restart) before preparing migration. Bound unconfirmed attempts independently from each prior worker's maximum in-flight dispatches; sum these across all identities. Do not assume zero merely because shutdown was requested. This preflight checks the supplied PID has exited, rejects audit overflow and existing ledger/history, and writes a private fsynced `wake-history.json`:
295
+
296
+ ```bash
297
+ node "$PKG/scripts/intercom-bridge-wake-preflight.mjs" \
298
+ --state-dir "$BRIDGE/state" --audit "$BRIDGE/logs/audit.jsonl" \
299
+ --complete-since 'ISO_TIME_AT_LEAST_ONE_HOUR_BEFORE_STOP' \
300
+ --stopped-at 'ISO_TIME_AFTER_ALL_LEGACY_WRITERS_STOPPED' \
301
+ --stopped-pid OLD_HELPER_PID --unknown-attempts PROVEN_BOUND_0_TO_20
302
+ ```
303
+
304
+ `--complete-since` attests verified coverage; outcomes alone cannot prove it. The operator must verify **all** previous writer processes are stopped, not just the checked PID, and no legacy writer may run after sealing history. If that proof or a bounded unknown count is unavailable, skip preflight and retain the conservative reservation. On first creation the helper seeds recorded attempts still within the sliding hour, plus unknown reservations at startup, preserving known remaining capacity. Existing ledgers never reseed; malformed, unreadable or corrupt state denies admission. Never manually edit/reset the database, switch state directories to gain quota, or roll back to a non-accounting helper without a new conservative reconciliation.
305
+
306
+ **Distributed ceiling.** URL grouping is local coalescing, not provider-account identity or exactly-once execution. Two simultaneously cold Macs can each issue a wake before either sees intake. Literal cross-Mac single admission requires a neutral admission authority governing every writer; none is introduced here. A known controlled allocation of two Box senders at 20 each (including their offline-peer POSTs), plus Work Mac 20 and Personal Mac 20 across all bots, totals 80 attempts/hour. That neither governs unrelated account traffic nor proves the provider's whole-account 100/hour cap. Offline-peer communication remains the Box owners' responsibility when both Macs sleep/offline; sleeping Macs cannot deliver locally until online, and stored queues remain retained.
277
307
 
278
308
  ### Sessions
279
309
 
@@ -380,20 +410,21 @@ Credential commands hold `DIR/.credentials.lock` for the whole update and fail r
380
410
  | `broker_unavailable`, or a refusal to start a second broker | Let the unhealthy broker exit, or start a normal Pi session, then retry. Don't delete broker files or kill a working broker. |
381
411
  | 507 `mailbox_storage_failed` | Fix disk space or `stateDir` permissions, then restart the helper. `lossRecorded:false` audit records mark uncounted losses. |
382
412
  | No fallback wakes | Check `wake_delivery` records (`http_rejected` with status, `transport_unconfirmed`, `storage_unavailable`) and the env file's mode and URL. Acked messages never wake. |
383
- | Repeated wakes for one `message_id` | Expected every 5 minutes after an `accepted` wake until the consumer acks the message. Fix the consumer's store-then-ack loop. |
413
+ | Repeated wakes for one `message_id` | Accepted-but-unacked batches retry on durable 30-second-to-five-minute backoff, within the shared quota. Fix the consumer's store-then-ack loop. |
384
414
  | Lost response, `task_unconfirmed` or `control_unconfirmed` | The outcome is unknown. Inspect the inbox, sessions or peer before retrying. |
385
415
  | `launch_in_progress` or `stop_timeout` | Inspect `GET /v1/sessions`; retry stop until it reports `exited`. |
386
416
  | `allowlist_reloaded` with `failed_closed` | The config is invalid; every identity is denied and owned sessions stopped. Fix the config and send SIGHUP again. |
387
417
 
388
418
  ## Verification
389
419
 
390
- The process-boundary tests use a private real broker, real certificates, real HTTPS and the remote CLI. Launch tests use the installed Pi dependency graph with a native faux provider, so no paid inference or external credentials are needed. Coverage includes cold SIGKILL restarts with mailbox and presence recovery, delayed fallback wakes and ack suppression, bounded cursor drains, storage failures, spawn and resume identity and model preservation, owned cancel and observed exit, native control authorization and old-owner timeouts, live transcripts and the saved-journal reader, same-CA server renewal with an unchanged client key, and restart-persistent groups.
420
+ The process-boundary tests use a private real broker, real certificates, real HTTPS and the remote CLI. Launch tests use the installed Pi dependency graph with a native faux provider, so no paid inference or external credentials are needed. Coverage includes cold SIGKILL restarts with mailbox and presence recovery, event-driven fallback, shared durable quota and ack suppression, bounded cursor drains, storage failures, spawn and resume identity and model preservation, owned cancel and observed exit, native control authorization and old-owner timeouts, live transcripts and the saved-journal reader, same-CA server renewal with an unchanged client key, and restart-persistent groups.
391
421
 
392
422
  ```bash
393
423
  node --test test/integration/intercom-bridge.test.ts test/integration/intercom-bridge-delivery.test.ts \
424
+ test/integration/intercom-bridge-wake-budget.test.ts test/integration/intercom-bridge-wake-state.test.ts \
394
425
  test/integration/intercom-bridge-launch.test.ts test/integration/intercom-bridge-lifecycle.test.ts \
395
426
  test/integration/intercom-native-control.test.ts test/unit/intercom-transcript.test.ts
396
427
  env -u PI_TIMING -u PI_EXTENSION_PERFORMANCE npm run ci
397
428
  ```
398
429
 
399
- For a live smoke test from the remote host, run `list`, ask one deliberately chosen session to reply `bridge-smoke-ok`, and fetch `GET /v1/sessions?live=true&saved=true`. Pause the remote consumer for more than two minutes to confirm a fallback wake. Keep only a redacted record: platform, route, accepted or delivered flags and the reply text. Never publish config, keys, wake URLs or raw inbox and list output.
430
+ For a live smoke test from the remote host, run `list`, ask one deliberately chosen session to reply `bridge-smoke-ok`, and fetch `GET /v1/sessions?live=true&saved=true`. With quota available, pause remote intake and send a deliberate actionable message to confirm fallback just after the two-second ACK grace; restore intake and verify actual ACK retires retry intent. Keep only a redacted record: platform, route, accepted or delivered flags and the reply text. Never publish config, keys, wake URLs or raw inbox and list output.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitchmultz/pi-subagents",
3
- "version": "0.48.0",
3
+ "version": "0.49.1",
4
4
  "description": "Pi extension for subagent orchestration and local session coordination",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { join } from "node:path";
4
+ import { existsSync } from "node:fs";
5
+ import { readBridgeFile, writeBridgeJson } from "../src/pi-intercom/bridge-files.ts";
6
+ import { stateDirectory } from "../src/pi-intercom/bridge-storage.ts";
7
+ import { isRecord } from "../src/shared/unknown.ts";
8
+
9
+ // Run only after proving this history covers every legacy worker and stopping those workers.
10
+ // --complete-since is the operator's coverage attestation, not something outcomes can prove.
11
+ const { values } = parseArgs({
12
+ options: {
13
+ "state-dir": { type: "string" },
14
+ audit: { type: "string" },
15
+ "complete-since": { type: "string" },
16
+ "stopped-at": { type: "string" },
17
+ "stopped-pid": { type: "string" },
18
+ "unknown-attempts": { type: "string" },
19
+ },
20
+ });
21
+ try {
22
+ const stateDir = stateDirectory(values["state-dir"]);
23
+ const coveredSince = Date.parse(values["complete-since"] ?? "");
24
+ const stoppedAt = Date.parse(values["stopped-at"] ?? "");
25
+ const pid = Number(values["stopped-pid"]);
26
+ const unknownAttempts = Number(values["unknown-attempts"]);
27
+ if (
28
+ stateDir === undefined ||
29
+ values.audit === undefined ||
30
+ !Number.isSafeInteger(coveredSince) ||
31
+ !Number.isSafeInteger(stoppedAt) ||
32
+ coveredSince > stoppedAt - 3600000 ||
33
+ stoppedAt > Date.now() ||
34
+ !Number.isSafeInteger(pid) ||
35
+ pid <= 0 ||
36
+ values["unknown-attempts"] === undefined ||
37
+ !Number.isInteger(unknownAttempts) ||
38
+ unknownAttempts < 0 ||
39
+ unknownAttempts > 20
40
+ ) {
41
+ throw new Error(
42
+ "Require state-dir, private audit, complete-since (at least one hour before stopped-at), stopped-at, stopped-pid and unknown-attempts (0..20).",
43
+ );
44
+ }
45
+ try {
46
+ process.kill(pid, 0);
47
+ throw new Error("Legacy helper is still running; stop it before preflight.");
48
+ } catch (error) {
49
+ if (error.code !== "ESRCH") {
50
+ throw error;
51
+ }
52
+ }
53
+ if (
54
+ existsSync(join(stateDir, "wake-quota.sqlite")) ||
55
+ existsSync(join(stateDir, "wake-history.json"))
56
+ ) {
57
+ throw new Error("Preflight cannot replace an existing ledger/history. Preserve it.");
58
+ }
59
+ const attempts = [];
60
+ const outcomes = new Set(["accepted", "http_rejected", "transport_unconfirmed"]);
61
+ const audit = readBridgeFile(values.audit, true, 32 * 1024 * 1024).toString("utf8");
62
+ for (const line of audit.split("\n")) {
63
+ if (line.trim() === "") {
64
+ continue;
65
+ }
66
+ /** @type {unknown} */
67
+ const raw = JSON.parse(line);
68
+ if (!isRecord(raw) || typeof raw.timestamp !== "string") {
69
+ throw new Error("Invalid audit record.");
70
+ }
71
+ const entry = raw;
72
+ const timestamp = Date.parse(entry.timestamp);
73
+ if (!Number.isSafeInteger(timestamp)) {
74
+ throw new Error("Audit record has no valid timestamp.");
75
+ }
76
+ if (timestamp < coveredSince || timestamp > stoppedAt) {
77
+ continue;
78
+ }
79
+ if (entry.event === "log_overflow") {
80
+ throw new Error(
81
+ "Audit overflow prevents complete-history migration; use conservative startup.",
82
+ );
83
+ }
84
+ if (
85
+ entry.event === "wake_delivery" &&
86
+ typeof entry.result === "string" &&
87
+ outcomes.has(entry.result)
88
+ ) {
89
+ attempts.push(timestamp);
90
+ }
91
+ }
92
+ if (attempts.length > 10000) {
93
+ throw new Error("Attempt history exceeds bounded migration input.");
94
+ }
95
+ writeBridgeJson(join(stateDir, "wake-history.json"), {
96
+ coveredSince,
97
+ stoppedAt,
98
+ attempts,
99
+ unknownAttempts,
100
+ });
101
+ console.log(
102
+ JSON.stringify({
103
+ result: "prepared",
104
+ recordedAttempts: attempts.length,
105
+ unknownAttempts,
106
+ note: "Start the new helper against this same stateDir; never copy/reset its ledger. Coverage and unknown-attempt bounds remain operator-owned proof.",
107
+ }),
108
+ );
109
+ } catch (error) {
110
+ console.error(error.message);
111
+ process.exitCode = 1;
112
+ }
@@ -15,9 +15,12 @@ Environment (required):
15
15
  Options:
16
16
  --to PEER Recipient name, peer ID or native ID for messages/controls
17
17
  --message TEXT Message for send/ask/reply
18
+ --id ID Send only: exact message ID (default: random; no deduplication guarantee)
19
+ --passive Send only: mailbox delivery without waking the recipient
18
20
  --reply-to ID Inbound ask ID from inbox, for reply
19
21
  --timeout-ms MS Ask: 100..120000 (default 120000); control: 100..10000 (default 3000)
20
22
  --after CURSOR Inbox cursor from nextCursor (default: 0)
23
+ --wait-ms MS Inbox: wait for new mail, 0..10000 (default: 0)
21
24
  --limit COUNT Inbox: 1..256; sessions/transcript: 1..100
22
25
  --name NAME Start/reuse a named idle Pi session under local policy
23
26
  --session-id ID Native ID for resume, stop or cancel
@@ -56,7 +59,7 @@ No automatic retries: a lost response does not prove a message was unsent.`);
56
59
 
57
60
  function allowedOptions(action) {
58
61
  const fields = {
59
- inbox: ["after", "limit"],
62
+ inbox: ["after", "limit", "wait-ms"],
60
63
  start: ["name"],
61
64
  spawn: ["name", "cwd", "task", "provider", "model"],
62
65
  resume: ["session-id", "name", "target", "task"],
@@ -68,7 +71,7 @@ function allowedOptions(action) {
68
71
  broadcast: ["message", "group"],
69
72
  "group-send": ["message", "group"],
70
73
  "group-upsert": ["name", "members", "name-pattern", "cwd-pattern"],
71
- send: ["to", "message"],
74
+ send: ["to", "message", "passive", "id"],
72
75
  ask: ["to", "message", "timeout-ms"],
73
76
  reply: ["reply-to", "message"],
74
77
  };
@@ -86,6 +89,16 @@ function requireMessage(recipient, message) {
86
89
  }
87
90
  }
88
91
 
92
+ function sendId(id) {
93
+ // Match the bridge's label bounds without changing the caller's ID.
94
+ // oxlint-disable-next-line no-control-regex
95
+ const controls = /[\u0000-\u001f\u007f-\u009f]/;
96
+ if (id !== undefined && (id.trim() === "" || id.length > 512 || controls.test(id))) {
97
+ throw new Error("Invalid message ID.");
98
+ }
99
+ return id;
100
+ }
101
+
89
102
  function resumeBody(options) {
90
103
  if (["name", "session-id", "target"].filter((key) => options[key] !== undefined).length !== 1) {
91
104
  throw new Error("Resume requires exactly one --name, --session-id or --target.");
@@ -182,7 +195,9 @@ function requestBody(action, options, ids, timeout) {
182
195
  return {
183
196
  to: recipient,
184
197
  message: options.message,
198
+ messageId: sendId(options.id),
185
199
  ...(action === "ask" ? { timeoutMs: timeout } : {}),
200
+ ...(options.passive === true ? { passive: true } : {}),
186
201
  };
187
202
  }
188
203
  if (action === "ack") {
@@ -219,15 +234,23 @@ function privateKey(path) {
219
234
  function inboxQuery(options) {
220
235
  const after = options.after ?? "0";
221
236
  const limit = options.limit ?? "256";
237
+ const waitMs = options["wait-ms"] ?? "0";
222
238
  if (
223
239
  !/^(0|[1-9]\d{0,15})$/.test(after) ||
224
240
  !Number.isSafeInteger(Number(after)) ||
225
241
  !/^[1-9]\d{0,2}$/.test(limit) ||
226
- Number(limit) > 256
242
+ Number(limit) > 256 ||
243
+ !/^(0|[1-9]\d{0,3}|10000)$/.test(waitMs)
227
244
  ) {
228
- throw new Error("--after must be a nonnegative safe integer cursor; --limit must be 1..256.");
245
+ throw new Error(
246
+ "--after must be a nonnegative safe integer cursor; --limit must be 1..256; --wait-ms must be 0..10000.",
247
+ );
229
248
  }
230
- return new URLSearchParams({ after, limit }).toString();
249
+ const query = new URLSearchParams({ after, limit });
250
+ if (options["wait-ms"] !== undefined) {
251
+ query.set("waitMs", waitMs);
252
+ }
253
+ return query.toString();
231
254
  }
232
255
 
233
256
  function requestQuery(action, options) {
@@ -245,13 +268,17 @@ let body, action, origin, timeout, query;
245
268
  try {
246
269
  const parsed = parseArgs({
247
270
  allowPositionals: true,
271
+ tokens: true,
248
272
  options: {
249
273
  help: { type: "boolean", short: "h" },
250
274
  to: { type: "string" },
251
275
  message: { type: "string" },
276
+ id: { type: "string" },
277
+ passive: { type: "boolean" },
252
278
  "reply-to": { type: "string" },
253
279
  "timeout-ms": { type: "string" },
254
280
  after: { type: "string" },
281
+ "wait-ms": { type: "string" },
255
282
  limit: { type: "string" },
256
283
  name: { type: "string" },
257
284
  "session-id": { type: "string" },
@@ -278,6 +305,13 @@ try {
278
305
  process.exit(0);
279
306
  }
280
307
  const options = parsed.values;
308
+ for (const name of ["wait-ms", "id"]) {
309
+ if (
310
+ parsed.tokens.filter((token) => token.kind === "option" && token.name === name).length > 1
311
+ ) {
312
+ throw new Error(`--${name} must not be repeated.`);
313
+ }
314
+ }
281
315
  [action] = parsed.positionals;
282
316
  if (
283
317
  ![
@@ -5,7 +5,7 @@ import { parseEnv } from "node:util";
5
5
  import { isUnknownArray } from "../shared/unknown.ts";
6
6
  import { BODY_LIMIT, object, label, fingerprint } from "./bridge-protocol.ts";
7
7
  import { launchPolicy, type LaunchPolicy } from "./bridge-launch-policy.ts";
8
- import { stateDirectory } from "./bridge-mailbox.ts";
8
+ import { stateDirectory } from "./bridge-storage.ts";
9
9
  import { readBridgeFile } from "./bridge-files.ts";
10
10
 
11
11
  const MAX_CLIENTS = 64;
@@ -1,6 +1,5 @@
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 type { DatabaseSync } from "node:sqlite";
2
+ import { privateDatabase } from "./bridge-storage.ts";
4
3
  import { isRecord, type UnknownRecord } from "../shared/unknown.ts";
5
4
  import { normalizeMessage, normalizeSessionInfo } from "./types.ts";
6
5
  import type { Envelope } from "./bridge-ask.ts";
@@ -9,7 +8,6 @@ import {
9
8
  INBOX_BYTES,
10
9
  INBOX_COUNT,
11
10
  fingerprint as certificateFingerprint,
12
- label,
13
11
  } from "./bridge-protocol.ts";
14
12
 
15
13
  interface Entry extends Envelope {
@@ -32,64 +30,10 @@ export interface NotificationPage extends InboxPage {
32
30
  readonly messages: readonly PendingNotification[];
33
31
  }
34
32
 
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
33
  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 });
34
+ const { db } = privateDatabase(stateDir, `${certificateFingerprint(fingerprint)}.sqlite`);
86
35
  try {
87
36
  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
37
  CREATE TABLE IF NOT EXISTS mailbox (
94
38
  sequence INTEGER PRIMARY KEY AUTOINCREMENT,
95
39
  id TEXT NOT NULL UNIQUE,
@@ -15,6 +15,7 @@ interface PresenceOwner {
15
15
  readonly stateDir: string;
16
16
  readonly clients: () => ReadonlyMap<string, Identity>;
17
17
  readonly connect: (identity: Identity, expires: number) => Promise<void>;
18
+ readonly restored: (identity: Identity, expires: number) => void;
18
19
  }
19
20
 
20
21
  export class PersistentPresence {
@@ -26,7 +27,11 @@ export class PersistentPresence {
26
27
 
27
28
  constructor(owner: PresenceOwner) {
28
29
  this.owner = owner;
29
- for (const identity of owner.clients().values()) {
30
+ this.restoreAll();
31
+ }
32
+
33
+ restoreAll(): void {
34
+ for (const identity of this.owner.clients().values()) {
30
35
  this.restore(identity);
31
36
  }
32
37
  }
@@ -55,6 +60,7 @@ export class PersistentPresence {
55
60
  cwd: identity.cwd,
56
61
  expires: value.expires,
57
62
  });
63
+ this.owner.restored(identity, value.expires);
58
64
  }
59
65
  } catch {
60
66
  // An absent/invalid saved registration never grants a certificate identity.