@fitchmultz/pi-subagents 0.47.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.
Files changed (73) hide show
  1. package/CHANGELOG.md +29 -3
  2. package/dist/extension/build-info.js +1 -1
  3. package/dist/pi-intercom/bridge-broadcast.js +60 -0
  4. package/dist/pi-intercom/bridge-catalog.js +372 -0
  5. package/dist/pi-intercom/bridge-config.js +15 -3
  6. package/dist/pi-intercom/bridge-control.js +104 -0
  7. package/dist/pi-intercom/bridge-groups.js +135 -0
  8. package/dist/pi-intercom/bridge-http.js +8 -1
  9. package/dist/pi-intercom/bridge-launch-journal.js +84 -0
  10. package/dist/pi-intercom/bridge-launch-policy.js +71 -5
  11. package/dist/pi-intercom/bridge-launch.js +186 -76
  12. package/dist/pi-intercom/bridge-mailbox.js +3 -55
  13. package/dist/pi-intercom/bridge-operations.js +127 -0
  14. package/dist/pi-intercom/bridge-presence.js +5 -1
  15. package/dist/pi-intercom/bridge-protocol.js +7 -0
  16. package/dist/pi-intercom/bridge-routes.js +195 -27
  17. package/dist/pi-intercom/bridge-rpc-process.js +43 -15
  18. package/dist/pi-intercom/bridge-session.js +83 -66
  19. package/dist/pi-intercom/bridge-storage.js +73 -0
  20. package/dist/pi-intercom/bridge-target.js +22 -0
  21. package/dist/pi-intercom/bridge-wake-owner.js +57 -0
  22. package/dist/pi-intercom/bridge-wake-state.js +293 -0
  23. package/dist/pi-intercom/bridge-wake.js +190 -99
  24. package/dist/pi-intercom/bridge.js +39 -39
  25. package/dist/pi-intercom/broker/client.js +1 -0
  26. package/dist/pi-intercom/connection.js +2 -3
  27. package/dist/pi-intercom/inbound.js +2 -4
  28. package/dist/pi-intercom/index.js +7 -1
  29. package/dist/pi-intercom/native-control-auth.js +80 -0
  30. package/dist/pi-intercom/native-control-protocol.js +111 -0
  31. package/dist/pi-intercom/native-control.js +130 -0
  32. package/dist/pi-intercom/saved-transcript.js +135 -0
  33. package/dist/pi-intercom/session-targets.js +3 -0
  34. package/dist/pi-intercom/transcript.js +175 -0
  35. package/docs/intercom-bridge.md +315 -174
  36. package/docs/intercom.md +1 -1
  37. package/docs/reference.md +1 -1
  38. package/package.json +1 -1
  39. package/scripts/intercom-bridge-wake-preflight.mjs +112 -0
  40. package/scripts/intercom-remote.mjs +213 -38
  41. package/src/pi-intercom/bridge-broadcast.ts +70 -0
  42. package/src/pi-intercom/bridge-catalog.ts +489 -0
  43. package/src/pi-intercom/bridge-config.ts +20 -3
  44. package/src/pi-intercom/bridge-control.ts +179 -0
  45. package/src/pi-intercom/bridge-groups.ts +169 -0
  46. package/src/pi-intercom/bridge-http.ts +15 -1
  47. package/src/pi-intercom/bridge-launch-journal.ts +122 -0
  48. package/src/pi-intercom/bridge-launch-policy.ts +88 -7
  49. package/src/pi-intercom/bridge-launch.ts +296 -107
  50. package/src/pi-intercom/bridge-mailbox.ts +3 -59
  51. package/src/pi-intercom/bridge-operations.ts +169 -0
  52. package/src/pi-intercom/bridge-presence.ts +7 -1
  53. package/src/pi-intercom/bridge-protocol.ts +8 -0
  54. package/src/pi-intercom/bridge-routes.ts +291 -49
  55. package/src/pi-intercom/bridge-rpc-process.ts +53 -16
  56. package/src/pi-intercom/bridge-session.ts +98 -96
  57. package/src/pi-intercom/bridge-storage.ts +83 -0
  58. package/src/pi-intercom/bridge-target.ts +35 -0
  59. package/src/pi-intercom/bridge-wake-owner.ts +58 -0
  60. package/src/pi-intercom/bridge-wake-state.ts +321 -0
  61. package/src/pi-intercom/bridge-wake.ts +224 -130
  62. package/src/pi-intercom/bridge.ts +41 -49
  63. package/src/pi-intercom/broker/client.ts +3 -0
  64. package/src/pi-intercom/connection.ts +2 -3
  65. package/src/pi-intercom/inbound.ts +2 -4
  66. package/src/pi-intercom/index.ts +7 -1
  67. package/src/pi-intercom/native-control-auth.ts +89 -0
  68. package/src/pi-intercom/native-control-protocol.ts +180 -0
  69. package/src/pi-intercom/native-control.ts +160 -0
  70. package/src/pi-intercom/saved-transcript.ts +175 -0
  71. package/src/pi-intercom/session-targets.ts +4 -0
  72. package/src/pi-intercom/transcript.ts +226 -0
  73. package/src/pi-intercom/types.ts +2 -0
@@ -1,146 +1,232 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { log } from "./bridge-protocol.js";
2
3
  import { Mailbox } from "./bridge-mailbox.js";
3
- const POLL_MS = 15000;
4
- const FALLBACK_AFTER_MS = 120000;
5
- const MAX_BACKOFF_MS = 300000;
6
- export class WakeDelivery {
4
+ import { WakeState } from "./bridge-wake-state.js";
5
+ function actionable(entry) {
6
+ const message = entry.message;
7
+ if (entry.acknowledged || message.passive === true || message.delivery === "passive") {
8
+ return false;
9
+ }
10
+ return (message.expectsReply === true ||
11
+ !/^(?:ACK|DUP-ACK|FYI|NOTE|RECEIPT|NOWAKE)(?=$|[\s:])/.test(message.content.text));
12
+ }
13
+ function bucket(identity) {
14
+ return createHash("sha256")
15
+ .update(identity.wake?.url ?? "")
16
+ .digest("hex");
17
+ }
18
+ /** Mailbox commits and durable retry/quota deadlines drive wakes; there is no intake scan timer. */
19
+ export class WakeDeliveries {
20
+ stateDir;
21
+ state;
22
+ allowed;
23
+ deliveries = new Map();
7
24
  controller = new AbortController();
8
25
  timer;
9
26
  running;
10
- backoff = POLL_MS;
11
- name;
12
- mailbox;
13
- policy;
14
- authorized;
15
- constructor(name, mailbox, policy, authorized) {
16
- this.name = name;
17
- this.mailbox = mailbox;
18
- this.policy = policy;
19
- this.authorized = authorized;
27
+ dirty = false;
28
+ storageBackoff = 15000;
29
+ constructor(stateDir, allowed) {
30
+ this.stateDir = stateDir;
31
+ this.allowed = allowed;
32
+ // Initialize before any presence proof/mailbox is created, so genuinely fresh installs start now.
33
+ this.state = stateDir === undefined ? undefined : new WakeState(stateDir);
34
+ }
35
+ session(identity, expires) {
36
+ if (!identity.wake ||
37
+ this.stateDir === undefined ||
38
+ this.deliveries.has(identity.fingerprint256)) {
39
+ return;
40
+ }
41
+ this.deliveries.set(identity.fingerprint256, {
42
+ identity,
43
+ expires,
44
+ mailbox: new Mailbox(this.stateDir, identity.fingerprint256, true),
45
+ controller: new AbortController(),
46
+ });
47
+ // Startup restores every certificate proof synchronously before the scheduled reconciliation.
48
+ // An empty first fingerprint must not clear another retained fingerprint's durable cold batch.
49
+ this.changed(false);
50
+ }
51
+ changed(reconcile = true) {
52
+ if (this.controller.signal.aborted) {
53
+ return;
54
+ }
55
+ try {
56
+ // ACK may be followed by fresh mail before the event timer runs. Clear completed batches now
57
+ // so a new obligation does not inherit a completed batch's uncertainty/backoff.
58
+ if (reconcile) {
59
+ this.reconcile();
60
+ }
61
+ }
62
+ catch {
63
+ // The scheduled storage recovery fails closed; never let an event bypass durable admission.
64
+ }
65
+ if (this.running) {
66
+ this.dirty = true;
67
+ }
68
+ else {
69
+ this.schedule(Date.now());
70
+ }
20
71
  }
21
- start() {
22
- if (!this.running && !this.controller.signal.aborted) {
23
- this.schedule(0);
72
+ reconcile() {
73
+ for (const [key, candidate] of this.groups()) {
74
+ if (candidate) {
75
+ this.state?.deadline(key);
76
+ }
77
+ else {
78
+ this.state?.retire(key);
79
+ }
24
80
  }
25
81
  }
26
- schedule(delay) {
82
+ schedule(deadline) {
27
83
  clearTimeout(this.timer);
28
84
  this.timer = setTimeout(() => {
85
+ this.dirty = false;
86
+ let next = Infinity;
29
87
  this.running = this.deliver()
88
+ .then((due) => {
89
+ next = due;
90
+ this.storageBackoff = 15000;
91
+ return;
92
+ })
30
93
  .catch(() => {
31
- log("wake_delivery", { name: this.name, result: "storage_unavailable" });
32
- this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
94
+ log("wake_delivery", { result: "storage_unavailable" });
95
+ next = Date.now() + this.storageBackoff;
96
+ this.storageBackoff = Math.min(this.storageBackoff * 2, 300000);
33
97
  })
34
98
  .finally(() => {
35
99
  this.running = undefined;
36
- if (!this.controller.signal.aborted) {
37
- this.schedule(this.backoff);
100
+ if (!this.controller.signal.aborted && (this.dirty || Number.isFinite(next))) {
101
+ this.schedule(this.dirty ? Date.now() : next);
38
102
  }
39
103
  });
40
- }, delay);
104
+ }, Math.max(0, Math.min(deadline - Date.now(), 0x7fffffff)));
105
+ }
106
+ authorized(delivery) {
107
+ return (!this.controller.signal.aborted &&
108
+ this.allowed(delivery.identity) &&
109
+ delivery.expires > Date.now());
110
+ }
111
+ pending(delivery) {
112
+ let after = 0;
113
+ let next;
114
+ let page;
115
+ do {
116
+ page = delivery.mailbox.pendingNotifications(after);
117
+ for (const entry of page.messages) {
118
+ if (!actionable(entry)) {
119
+ // Retire only notification intent. Payload/reply obligations require actual ACK/reply.
120
+ delivery.mailbox.notificationAccepted(entry.message.id);
121
+ }
122
+ else if (!next) {
123
+ next = entry;
124
+ }
125
+ }
126
+ after = Number(page.nextCursor);
127
+ } while (page.hasMore);
128
+ return next;
129
+ }
130
+ groups() {
131
+ const groups = new Map();
132
+ for (const delivery of this.deliveries.values()) {
133
+ if (!this.authorized(delivery)) {
134
+ continue;
135
+ }
136
+ const key = bucket(delivery.identity);
137
+ const entry = this.pending(delivery);
138
+ const previous = groups.get(key);
139
+ if (!groups.has(key) ||
140
+ (entry && (!previous || entry.receivedAt < previous.entry.receivedAt))) {
141
+ groups.set(key, entry ? { delivery, entry } : undefined);
142
+ }
143
+ }
144
+ return groups;
41
145
  }
42
146
  async deliver() {
43
- if (!this.authorized()) {
44
- return;
147
+ const state = this.state;
148
+ let next = Infinity;
149
+ if (!state) {
150
+ return next;
45
151
  }
46
- const entries = this.mailbox.pendingNotifications().messages;
47
- for (const entry of entries) {
48
- if (entry.acknowledged) {
49
- // The box's durable worker owns primary wake/replay after acknowledgement.
50
- this.mailbox.notificationAccepted(entry.message.id);
152
+ // ACK can remove the final candidate while its failed completion write still needs recovery.
153
+ state.retrySettlement();
154
+ for (const [key, candidate] of this.groups()) {
155
+ if (!candidate) {
156
+ state.retire(key);
157
+ continue;
51
158
  }
159
+ const due = state.deadline(key);
160
+ if (due > Date.now()) {
161
+ next = Math.min(next, due);
162
+ continue;
163
+ }
164
+ const deferred = state.reserve(key);
165
+ if (deferred !== undefined) {
166
+ next = Math.min(next, deferred);
167
+ continue;
168
+ }
169
+ // ponytail: two cold Macs can each admit one wake. Literal cross-Mac single admission
170
+ // requires a neutral quota authority governing every writer, not a URL hash or HTTP 2xx.
171
+ try {
172
+ // oxlint-disable-next-line no-await-in-loop
173
+ await this.post(candidate);
174
+ }
175
+ finally {
176
+ // Expiry starts after response/error/abort, never before COMMIT or connect/TLS.
177
+ state.settle();
178
+ }
179
+ // Re-read ACKs and all groups after transport I/O; a snapshot must not wake already consumed mail.
180
+ return Date.now();
52
181
  }
53
- const next = entries.find((entry) => !entry.acknowledged && Date.now() - entry.receivedAt >= FALLBACK_AFTER_MS);
54
- if (next) {
55
- // One fallback wakes the box to drain the remaining mailbox; never flood a reset VM.
56
- await this.post(next);
57
- return;
58
- }
59
- this.backoff = POLL_MS;
182
+ return next;
60
183
  }
61
- async post(entry) {
62
- if (!this.authorized()) {
184
+ async post({ delivery, entry }) {
185
+ const policy = delivery.identity.wake;
186
+ if (!policy || !this.authorized(delivery)) {
63
187
  return;
64
188
  }
65
- const fields = { name: this.name, messageId: entry.message.id };
189
+ const fields = { name: delivery.identity.name, messageId: entry.message.id };
66
190
  try {
67
- const response = await fetch(this.policy.url, {
191
+ const response = await fetch(policy.url, {
68
192
  method: "POST",
69
193
  redirect: "error",
70
- headers: {
71
- Authorization: `Bearer ${this.policy.key}`,
72
- "Content-Type": "application/json",
73
- },
194
+ headers: { Authorization: `Bearer ${policy.key}`, "Content-Type": "application/json" },
74
195
  body: JSON.stringify({
75
196
  from: entry.from.name ?? entry.from.id,
76
197
  from_id: entry.from.id,
77
- to: this.name,
198
+ to: delivery.identity.name,
78
199
  message_id: entry.message.id,
79
200
  ask_id: entry.message.expectsReply === true ? entry.message.id : null,
80
201
  text: entry.message.content.text,
81
202
  }),
82
- signal: AbortSignal.any([this.controller.signal, AbortSignal.timeout(10000)]),
203
+ signal: AbortSignal.any([
204
+ this.controller.signal,
205
+ delivery.controller.signal,
206
+ AbortSignal.timeout(10000),
207
+ ]),
83
208
  });
84
209
  await response.body?.cancel();
85
- if (response.ok && !this.controller.signal.aborted && this.authorized()) {
86
- // HTTP acceptance cannot prove durable intake; the same ID remains pending until inbox ACK.
87
- this.backoff = MAX_BACKOFF_MS;
88
- log("wake_delivery", { ...fields, result: "accepted" });
89
- }
90
- else {
91
- this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
92
- log("wake_delivery", { ...fields, result: "http_rejected", status: response.status });
93
- }
210
+ // Acceptance does not consume mail or reset uncertainty: only real inbox ACK retires intent.
211
+ log("wake_delivery", {
212
+ ...fields,
213
+ result: response.ok ? "accepted" : "http_rejected",
214
+ status: response.status,
215
+ });
94
216
  }
95
217
  catch {
96
- this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
97
218
  if (!this.controller.signal.aborted) {
98
219
  log("wake_delivery", { ...fields, result: "transport_unconfirmed" });
99
220
  }
100
221
  }
101
222
  }
102
- async stop() {
103
- this.controller.abort();
104
- clearTimeout(this.timer);
105
- await this.running;
106
- }
107
- }
108
- export class WakeDeliveries {
109
- stateDir;
110
- allowed;
111
- deliveries = new Map();
112
- cleanup = new Set();
113
- constructor(stateDir, allowed) {
114
- this.stateDir = stateDir;
115
- this.allowed = allowed;
116
- }
117
- session(identity, expires) {
118
- if (!identity.wake ||
119
- this.stateDir === undefined ||
120
- this.deliveries.has(identity.fingerprint256)) {
121
- return;
122
- }
123
- const mailbox = new Mailbox(this.stateDir, identity.fingerprint256, true);
124
- const worker = new WakeDelivery(identity.name, mailbox, identity.wake, () => this.allowed(identity) && expires > Date.now());
125
- this.deliveries.set(identity.fingerprint256, { identity, mailbox, worker });
126
- worker.start();
127
- }
128
223
  drop(fp) {
129
224
  const delivery = this.deliveries.get(fp);
130
- if (!delivery) {
131
- return;
132
- }
133
225
  this.deliveries.delete(fp);
134
- const cleanup = delivery.worker
135
- .stop()
136
- .then(() => delivery.mailbox.close())
137
- .catch(() => {
138
- log("wake_delivery", { name: delivery.identity.name, result: "cleanup_failed" });
139
- })
140
- .finally(() => {
141
- this.cleanup.delete(cleanup);
142
- });
143
- this.cleanup.add(cleanup);
226
+ delivery?.controller.abort();
227
+ delivery?.mailbox.close();
228
+ // Membership removal alone is not an ACK. Reload restores proof-backed replacements first.
229
+ this.changed(false);
144
230
  }
145
231
  reload() {
146
232
  for (const [fp, delivery] of this.deliveries) {
@@ -148,11 +234,16 @@ export class WakeDeliveries {
148
234
  this.drop(fp);
149
235
  }
150
236
  }
237
+ this.changed(false);
151
238
  }
152
239
  async stop() {
153
- for (const fp of this.deliveries.keys()) {
154
- this.drop(fp);
240
+ this.controller.abort();
241
+ clearTimeout(this.timer);
242
+ await this.running;
243
+ for (const delivery of this.deliveries.values()) {
244
+ delivery.mailbox.close();
155
245
  }
156
- await Promise.all(this.cleanup);
246
+ this.deliveries.clear();
247
+ this.state?.close();
157
248
  }
158
249
  }
@@ -7,12 +7,21 @@ import { loadConfig, sameListener } from "./bridge-config.js";
7
7
  import { ASK_TIMEOUT, Fault, fingerprint, log } from "./bridge-protocol.js";
8
8
  import { Session } from "./bridge-session.js";
9
9
  import { operationFor, routeFor } from "./bridge-routes.js";
10
- import { reply } from "./bridge-http.js";
11
- import { Launcher, sameIdentity } from "./bridge-launch.js";
10
+ import { reply, replyFault } from "./bridge-http.js";
11
+ import { sameIdentity } from "./bridge-launch.js";
12
+ import { BridgeOperations } from "./bridge-operations.js";
13
+ import { authenticatedCertificateChain } from "./native-control-auth.js";
12
14
  import { PersistentPresence } from "./bridge-presence.js";
13
15
  import { WakeDeliveries } from "./bridge-wake.js";
14
16
  import { spawnBrokerIfNeeded } from "./broker/spawn.js";
15
17
  const MAX_CONNECTIONS = 128;
18
+ const LONG_ROUTES = new Set([
19
+ "POST /v1/start",
20
+ "POST /v1/spawn",
21
+ "POST /v1/resume",
22
+ "GET /v1/transcript",
23
+ "POST /v1/transcript",
24
+ ]);
16
25
  function validCertificateDates(starts, expires) {
17
26
  return (Number.isFinite(starts) &&
18
27
  Number.isFinite(expires) &&
@@ -24,7 +33,7 @@ class Bridge {
24
33
  configPath;
25
34
  sessions = new Map();
26
35
  losses = new Map();
27
- launcher = new Launcher();
36
+ operations;
28
37
  sockets = new Set();
29
38
  connections = new Map();
30
39
  server;
@@ -35,11 +44,13 @@ class Bridge {
35
44
  constructor(config, configPath) {
36
45
  this.config = config;
37
46
  this.configPath = configPath;
47
+ this.operations = new BridgeOperations(config.stateDir);
38
48
  this.deliveries = new WakeDeliveries(config.stateDir, (identity) => this.allowed(identity));
39
49
  if (config.stateDir !== undefined) {
40
50
  this.presence = new PersistentPresence({
41
51
  stateDir: config.stateDir,
42
52
  clients: () => this.config.clients,
53
+ restored: (identity, expires) => this.deliveries.session(identity, expires),
43
54
  connect: async (identity, expires) => {
44
55
  await spawnBrokerIfNeeded(process.execPath, []);
45
56
  if (!this.allowed(identity)) {
@@ -122,6 +133,7 @@ class Bridge {
122
133
  session = new Session({
123
134
  stateDir: this.config.stateDir,
124
135
  notifications: identity.wake !== undefined,
136
+ mailboxChanged: () => this.deliveries.changed(),
125
137
  allowed: (candidate) => this.allowed(candidate),
126
138
  lost: (fp, count) => this.lost(fp, count),
127
139
  losses: (fp) => this.losses.get(fp) ?? 0,
@@ -221,13 +233,13 @@ class Bridge {
221
233
  }
222
234
  this.config = next;
223
235
  this.revokeRemoved();
224
- this.launcher.revoke(next.clients).catch(() => log("launch_cleanup", { result: "failed" }));
236
+ this.operations.revoke(next.clients).catch(() => log("launch_cleanup", { result: "failed" }));
225
237
  log("allowlist_reloaded", { result: "accepted", clients: next.clients.size });
226
238
  }
227
239
  catch {
228
240
  this.config = { ...this.config, clients: new Map() };
229
241
  this.deliveries.reload();
230
- this.launcher
242
+ this.operations
231
243
  .revoke(this.config.clients)
232
244
  .catch(() => log("launch_cleanup", { result: "failed" }));
233
245
  for (const session of this.sessions.values()) {
@@ -257,6 +269,9 @@ class Bridge {
257
269
  this.losses.delete(fp);
258
270
  }
259
271
  }
272
+ // Retire stale sessions before publishing replacement delivery membership for the same pin.
273
+ this.presence?.restoreAll();
274
+ this.deliveries.changed();
260
275
  }
261
276
  stop() {
262
277
  this.shutdown().catch(() => {
@@ -280,7 +295,7 @@ class Bridge {
280
295
  await Promise.all([
281
296
  ...this.handlers,
282
297
  ...sessions.map((session) => session.settled()),
283
- this.launcher.shutdown(),
298
+ this.operations.shutdown(),
284
299
  this.presence?.stop(),
285
300
  this.deliveries.stop(),
286
301
  ]);
@@ -302,7 +317,11 @@ class Bridge {
302
317
  this.drop(connection.fingerprint, fault);
303
318
  throw fault;
304
319
  }
305
- return { identity, expires: connection.expires };
320
+ return {
321
+ identity,
322
+ expires: connection.expires,
323
+ certificate: authenticatedCertificateChain(socket),
324
+ };
306
325
  }
307
326
  async handle(req, res) {
308
327
  const requestId = randomUUID();
@@ -311,7 +330,9 @@ class Bridge {
311
330
  let controller;
312
331
  let deadline;
313
332
  let peer;
314
- 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."));
315
336
  const onClose = () => {
316
337
  if (!res.writableFinished) {
317
338
  abort();
@@ -319,26 +340,31 @@ class Bridge {
319
340
  };
320
341
  const setDeadline = (ms) => {
321
342
  clearTimeout(deadline);
322
- 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)));
323
344
  };
324
345
  try {
325
- const { identity, expires } = this.admission(req);
346
+ const { identity, expires, certificate } = this.admission(req);
326
347
  const route = routeFor(req);
348
+ readOnlyInbox = route === "GET /v1/inbox";
327
349
  session = this.session(identity, expires);
328
350
  controller = session.begin();
329
351
  const signal = controller.signal;
330
352
  req.once("aborted", abort);
331
353
  res.once("close", onClose);
332
354
  const duration = route === "POST /v1/ask" ? ASK_TIMEOUT + 15000 : 15000;
333
- setDeadline(route === "POST /v1/start" ? 75000 : duration);
355
+ setDeadline(LONG_ROUTES.has(route) ? 75000 : duration);
334
356
  const operation = await operationFor(req, route, signal);
335
357
  if (operation.route === "POST /v1/ask") {
336
358
  setDeadline(operation.timeoutMs + 15000);
337
359
  }
338
- const data = await this.execute(operation, session, {
360
+ else if (operation.route === "GET /v1/transcript") {
361
+ setDeadline(75000);
362
+ }
363
+ const data = await this.operations.execute(operation, session, {
339
364
  signal,
340
365
  requestId,
341
366
  expires,
367
+ certificate,
342
368
  onPeer: (id) => {
343
369
  peer = id;
344
370
  },
@@ -352,10 +378,7 @@ class Bridge {
352
378
  ? error
353
379
  : new Fault(503, "broker_unavailable", "Local broker operation failed.");
354
380
  log("request", { ...session?.fields(), requestId, peer, result: fault.code });
355
- reply(res, requestId, fault.status, {
356
- ok: false,
357
- error: { code: fault.code, message: fault.message, ...fault.details },
358
- });
381
+ replyFault(res, requestId, fault);
359
382
  }
360
383
  finally {
361
384
  clearTimeout(deadline);
@@ -366,29 +389,6 @@ class Bridge {
366
389
  }
367
390
  }
368
391
  }
369
- async execute(operation, session, context) {
370
- switch (operation.route) {
371
- case "POST /v1/start":
372
- return this.launcher.start(operation.name, {
373
- identity: session.identity,
374
- expires: context.expires,
375
- signal: context.signal,
376
- peers: (signal) => session.connectedPeers(signal),
377
- });
378
- case "GET /v1/sessions":
379
- return this.launcher.list(session.identity);
380
- case "POST /v1/stop":
381
- return this.launcher.stop(session.identity, operation.sessionId);
382
- case "GET /v1/list":
383
- case "GET /v1/inbox":
384
- case "POST /v1/register":
385
- case "POST /v1/ask":
386
- case "POST /v1/send":
387
- case "POST /v1/reply":
388
- case "POST /v1/ack":
389
- return session.execute(operation, context);
390
- }
391
- }
392
392
  }
393
393
  function main() {
394
394
  const args = process.argv.slice(2);
@@ -320,6 +320,7 @@ export class IntercomClient extends EventEmitter {
320
320
  expectsReply: options.expectsReply,
321
321
  ...(options.human ? { human: options.human } : {}),
322
322
  ...(options.topic ? { topic: options.topic } : {}),
323
+ ...(options.control ? { control: options.control } : {}),
323
324
  delivery: messageDelivery(options),
324
325
  queueMode: options.queueMode,
325
326
  threadId: options.threadId,
@@ -1,8 +1,7 @@
1
- import { createHash } from "node:crypto";
2
1
  import { IntercomClient } from "./broker/client.js";
3
2
  import { spawnBrokerIfNeeded } from "./broker/spawn.js";
4
3
  import { asError, errorMessage } from "./validation.js";
5
- import { formatTargetOptions, resolveSessionTarget } from "./session-targets.js";
4
+ import { formatTargetOptions, resolveSessionTarget, nativePeerId } from "./session-targets.js";
6
5
  /** Owns socket registration, coalesced connects and the reconnect/startup timers. */
7
6
  export class IntercomConnection {
8
7
  client = null;
@@ -195,7 +194,7 @@ export class IntercomConnection {
195
194
  if (reason !== "peer-awareness") {
196
195
  await spawnBrokerIfNeeded(this.config.brokerCommand, this.config.brokerArgs);
197
196
  }
198
- await next.connect(await this.lifecycle.registration(this.hooks.pendingAsks()), `pi-${createHash("sha256").update(sessionId).digest("hex").slice(0, 32)}`);
197
+ await next.connect(await this.lifecycle.registration(this.hooks.pendingAsks()), nativePeerId(sessionId));
199
198
  if (!this.lifecycle.live(ctx, generation)) {
200
199
  await next.disconnect();
201
200
  throw new Error("Intercom runtime no longer active");
@@ -1,7 +1,7 @@
1
- import { createHash } from "node:crypto";
2
1
  import { requestedDelivery, isBlockingSupervisorMessage } from "./inbound-record.js";
3
2
  import { formatAttachments } from "./message-format.js";
4
3
  import { readQuestionOwner } from "../runs/shared/supervisor-questions.js";
4
+ import { nativePeerId } from "./session-targets.js";
5
5
  /** Validates origin and chooses admission; state/resources remain in their owners. */
6
6
  export class IntercomInbound {
7
7
  lifecycle;
@@ -19,9 +19,7 @@ export class IntercomInbound {
19
19
  return false;
20
20
  }
21
21
  const owner = readQuestionOwner(origin.runId);
22
- return (owner?.sessionId === origin.ownerSessionId &&
23
- from.id ===
24
- `pi-${createHash("sha256").update(origin.ownerSessionId).digest("hex").slice(0, 32)}`);
22
+ return (owner?.sessionId === origin.ownerSessionId && from.id === nativePeerId(origin.ownerSessionId));
25
23
  }
26
24
  normalize(from, input) {
27
25
  const message = input.topic
@@ -8,6 +8,7 @@ import { InboundDeliveryOwner } from "./inbound-delivery.js";
8
8
  import { ReplyWait } from "./reply-wait.js";
9
9
  import { IntercomConnection } from "./connection.js";
10
10
  import { IntercomInbound } from "./inbound.js";
11
+ import { NativeControls } from "./native-control.js";
11
12
  import { IntercomOutbound } from "./outbound.js";
12
13
  import { IntercomInspection } from "./inspection.js";
13
14
  import { IntercomTopicActions } from "./topic-actions.js";
@@ -31,7 +32,11 @@ export default function piIntercomExtension(pi) {
31
32
  const connection = new IntercomConnection(lifecycle, config, topics, {
32
33
  restoring: () => delivery.restoring,
33
34
  pendingAsks: () => replies.listPending().length,
34
- incoming: (ctx, from, message) => inbound.receive(ctx, from, message),
35
+ incoming: async (ctx, from, message) => {
36
+ if (!(await controls.receive(ctx, from, message))) {
37
+ await inbound.receive(ctx, from, message);
38
+ }
39
+ },
35
40
  peerLeft: (id) => {
36
41
  wait.peerDisconnected(id);
37
42
  journal.peerLeft(id);
@@ -42,6 +47,7 @@ export default function piIntercomExtension(pi) {
42
47
  }
43
48
  },
44
49
  });
50
+ const controls = new NativeControls(lifecycle, connection);
45
51
  const delivery = new InboundDeliveryOwner(pi, lifecycle, journal, {
46
52
  replies,
47
53
  syncStatus: () => connection.syncStatus(),