@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,174 +1,263 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { log } from "./bridge-protocol.ts";
2
3
  import { Mailbox, type PendingNotification } from "./bridge-mailbox.ts";
3
- import type { Identity, WakePolicy } from "./bridge-config.ts";
4
+ import type { Identity } from "./bridge-config.ts";
5
+ import { WakeState } from "./bridge-wake-state.ts";
4
6
 
5
- const POLL_MS = 15000;
6
- const FALLBACK_AFTER_MS = 120000;
7
- const MAX_BACKOFF_MS = 300000;
7
+ interface Delivery {
8
+ readonly identity: Identity;
9
+ readonly mailbox: Mailbox;
10
+ readonly expires: number;
11
+ readonly controller: AbortController;
12
+ }
13
+ interface Candidate {
14
+ readonly delivery: Delivery;
15
+ readonly entry: PendingNotification;
16
+ }
17
+
18
+ function actionable(entry: PendingNotification): boolean {
19
+ const message = entry.message;
20
+ if (entry.acknowledged || message.passive === true || message.delivery === "passive") {
21
+ return false;
22
+ }
23
+ return (
24
+ message.expectsReply === true ||
25
+ !/^(?:ACK|DUP-ACK|FYI|NOTE|RECEIPT|NOWAKE)(?=$|[\s:])/.test(message.content.text)
26
+ );
27
+ }
28
+
29
+ function bucket(identity: Identity): string {
30
+ return createHash("sha256")
31
+ .update(identity.wake?.url ?? "")
32
+ .digest("hex");
33
+ }
8
34
 
9
- export class WakeDelivery {
35
+ /** Mailbox commits and durable retry/quota deadlines drive wakes; there is no intake scan timer. */
36
+ export class WakeDeliveries {
37
+ private readonly stateDir: string | undefined;
38
+ private readonly state?: WakeState;
39
+ private readonly allowed: (identity: Identity) => boolean;
40
+ private readonly deliveries = new Map<string, Delivery>();
10
41
  private readonly controller = new AbortController();
11
42
  private timer?: NodeJS.Timeout;
12
43
  private running?: Promise<void>;
13
- private backoff = POLL_MS;
14
- private readonly name: string;
15
- private readonly mailbox: Pick<Mailbox, "pendingNotifications" | "notificationAccepted">;
16
- private readonly policy: WakePolicy;
17
- private readonly authorized: () => boolean;
18
-
19
- constructor(
20
- name: string,
21
- mailbox: Pick<Mailbox, "pendingNotifications" | "notificationAccepted">,
22
- policy: WakePolicy,
23
- authorized: () => boolean,
24
- ) {
25
- this.name = name;
26
- this.mailbox = mailbox;
27
- this.policy = policy;
28
- this.authorized = authorized;
29
- }
44
+ private dirty = false;
45
+ private storageBackoff = 15000;
30
46
 
31
- start(): void {
32
- if (!this.running && !this.controller.signal.aborted) {
33
- this.schedule(0);
34
- }
47
+ constructor(stateDir: string | undefined, allowed: (identity: Identity) => boolean) {
48
+ this.stateDir = stateDir;
49
+ this.allowed = allowed;
50
+ // Initialize before any presence proof/mailbox is created, so genuinely fresh installs start now.
51
+ this.state = stateDir === undefined ? undefined : new WakeState(stateDir);
35
52
  }
36
53
 
37
- private schedule(delay: number): void {
38
- clearTimeout(this.timer);
39
- this.timer = setTimeout(() => {
40
- this.running = this.deliver()
41
- .catch(() => {
42
- log("wake_delivery", { name: this.name, result: "storage_unavailable" });
43
- this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
44
- })
45
- .finally(() => {
46
- this.running = undefined;
47
- if (!this.controller.signal.aborted) {
48
- this.schedule(this.backoff);
49
- }
50
- });
51
- }, delay);
54
+ session(identity: Identity, expires: number): void {
55
+ if (
56
+ !identity.wake ||
57
+ this.stateDir === undefined ||
58
+ this.deliveries.has(identity.fingerprint256)
59
+ ) {
60
+ return;
61
+ }
62
+ this.deliveries.set(identity.fingerprint256, {
63
+ identity,
64
+ expires,
65
+ mailbox: new Mailbox(this.stateDir, identity.fingerprint256, true),
66
+ controller: new AbortController(),
67
+ });
68
+ // Startup restores every certificate proof synchronously before the scheduled reconciliation.
69
+ // An empty first fingerprint must not clear another retained fingerprint's durable cold batch.
70
+ this.changed(false);
52
71
  }
53
72
 
54
- private async deliver(): Promise<void> {
55
- if (!this.authorized()) {
73
+ changed(reconcile = true): void {
74
+ if (this.controller.signal.aborted) {
56
75
  return;
57
76
  }
58
- const entries = this.mailbox.pendingNotifications().messages;
59
- for (const entry of entries) {
60
- if (entry.acknowledged) {
61
- // The box's durable worker owns primary wake/replay after acknowledgement.
62
- this.mailbox.notificationAccepted(entry.message.id);
77
+ try {
78
+ // ACK may be followed by fresh mail before the event timer runs. Clear completed batches now
79
+ // so a new obligation does not inherit a completed batch's uncertainty/backoff.
80
+ if (reconcile) {
81
+ this.reconcile();
63
82
  }
83
+ } catch {
84
+ // The scheduled storage recovery fails closed; never let an event bypass durable admission.
64
85
  }
65
- const next = entries.find(
66
- (entry) => !entry.acknowledged && Date.now() - entry.receivedAt >= FALLBACK_AFTER_MS,
86
+ if (this.running) {
87
+ this.dirty = true;
88
+ } else {
89
+ this.schedule(Date.now());
90
+ }
91
+ }
92
+
93
+ private reconcile(): void {
94
+ for (const [key, candidate] of this.groups()) {
95
+ if (candidate) {
96
+ this.state?.deadline(key);
97
+ } else {
98
+ this.state?.retire(key);
99
+ }
100
+ }
101
+ }
102
+
103
+ private schedule(deadline: number): void {
104
+ clearTimeout(this.timer);
105
+ this.timer = setTimeout(
106
+ () => {
107
+ this.dirty = false;
108
+ let next = Infinity;
109
+ this.running = this.deliver()
110
+ .then((due) => {
111
+ next = due;
112
+ this.storageBackoff = 15000;
113
+ return;
114
+ })
115
+ .catch(() => {
116
+ log("wake_delivery", { result: "storage_unavailable" });
117
+ next = Date.now() + this.storageBackoff;
118
+ this.storageBackoff = Math.min(this.storageBackoff * 2, 300000);
119
+ })
120
+ .finally(() => {
121
+ this.running = undefined;
122
+ if (!this.controller.signal.aborted && (this.dirty || Number.isFinite(next))) {
123
+ this.schedule(this.dirty ? Date.now() : next);
124
+ }
125
+ });
126
+ },
127
+ Math.max(0, Math.min(deadline - Date.now(), 0x7fffffff)),
67
128
  );
68
- if (next) {
69
- // One fallback wakes the box to drain the remaining mailbox; never flood a reset VM.
70
- await this.post(next);
71
- return;
129
+ }
130
+
131
+ private authorized(delivery: Delivery): boolean {
132
+ return (
133
+ !this.controller.signal.aborted &&
134
+ this.allowed(delivery.identity) &&
135
+ delivery.expires > Date.now()
136
+ );
137
+ }
138
+
139
+ private pending(delivery: Delivery): PendingNotification | undefined {
140
+ let after = 0;
141
+ let next: PendingNotification | undefined;
142
+ let page;
143
+ do {
144
+ page = delivery.mailbox.pendingNotifications(after);
145
+ for (const entry of page.messages) {
146
+ if (!actionable(entry)) {
147
+ // Retire only notification intent. Payload/reply obligations require actual ACK/reply.
148
+ delivery.mailbox.notificationAccepted(entry.message.id);
149
+ } else if (!next) {
150
+ next = entry;
151
+ }
152
+ }
153
+ after = Number(page.nextCursor);
154
+ } while (page.hasMore);
155
+ return next;
156
+ }
157
+
158
+ private groups(): Map<string, Candidate | undefined> {
159
+ const groups = new Map<string, Candidate | undefined>();
160
+ for (const delivery of this.deliveries.values()) {
161
+ if (!this.authorized(delivery)) {
162
+ continue;
163
+ }
164
+ const key = bucket(delivery.identity);
165
+ const entry = this.pending(delivery);
166
+ const previous = groups.get(key);
167
+ if (
168
+ !groups.has(key) ||
169
+ (entry && (!previous || entry.receivedAt < previous.entry.receivedAt))
170
+ ) {
171
+ groups.set(key, entry ? { delivery, entry } : undefined);
172
+ }
173
+ }
174
+ return groups;
175
+ }
176
+
177
+ private async deliver(): Promise<number> {
178
+ const state = this.state;
179
+ let next = Infinity;
180
+ if (!state) {
181
+ return next;
182
+ }
183
+ // ACK can remove the final candidate while its failed completion write still needs recovery.
184
+ state.retrySettlement();
185
+ for (const [key, candidate] of this.groups()) {
186
+ if (!candidate) {
187
+ state.retire(key);
188
+ continue;
189
+ }
190
+ const due = state.deadline(key);
191
+ if (due > Date.now()) {
192
+ next = Math.min(next, due);
193
+ continue;
194
+ }
195
+ const deferred = state.reserve(key);
196
+ if (deferred !== undefined) {
197
+ next = Math.min(next, deferred);
198
+ continue;
199
+ }
200
+ // ponytail: two cold Macs can each admit one wake. Literal cross-Mac single admission
201
+ // requires a neutral quota authority governing every writer, not a URL hash or HTTP 2xx.
202
+ try {
203
+ // oxlint-disable-next-line no-await-in-loop
204
+ await this.post(candidate);
205
+ } finally {
206
+ // Expiry starts after response/error/abort, never before COMMIT or connect/TLS.
207
+ state.settle();
208
+ }
209
+ // Re-read ACKs and all groups after transport I/O; a snapshot must not wake already consumed mail.
210
+ return Date.now();
72
211
  }
73
- this.backoff = POLL_MS;
212
+ return next;
74
213
  }
75
214
 
76
- private async post(entry: PendingNotification): Promise<void> {
77
- if (!this.authorized()) {
215
+ private async post({ delivery, entry }: Candidate): Promise<void> {
216
+ const policy = delivery.identity.wake;
217
+ if (!policy || !this.authorized(delivery)) {
78
218
  return;
79
219
  }
80
- const fields = { name: this.name, messageId: entry.message.id };
220
+ const fields = { name: delivery.identity.name, messageId: entry.message.id };
81
221
  try {
82
- const response = await fetch(this.policy.url, {
222
+ const response = await fetch(policy.url, {
83
223
  method: "POST",
84
224
  redirect: "error",
85
- headers: {
86
- Authorization: `Bearer ${this.policy.key}`,
87
- "Content-Type": "application/json",
88
- },
225
+ headers: { Authorization: `Bearer ${policy.key}`, "Content-Type": "application/json" },
89
226
  body: JSON.stringify({
90
227
  from: entry.from.name ?? entry.from.id,
91
228
  from_id: entry.from.id,
92
- to: this.name,
229
+ to: delivery.identity.name,
93
230
  message_id: entry.message.id,
94
231
  ask_id: entry.message.expectsReply === true ? entry.message.id : null,
95
232
  text: entry.message.content.text,
96
233
  }),
97
- signal: AbortSignal.any([this.controller.signal, AbortSignal.timeout(10000)]),
234
+ signal: AbortSignal.any([
235
+ this.controller.signal,
236
+ delivery.controller.signal,
237
+ AbortSignal.timeout(10000),
238
+ ]),
98
239
  });
99
240
  await response.body?.cancel();
100
- if (response.ok && !this.controller.signal.aborted && this.authorized()) {
101
- // HTTP acceptance cannot prove durable intake; the same ID remains pending until inbox ACK.
102
- this.backoff = MAX_BACKOFF_MS;
103
- log("wake_delivery", { ...fields, result: "accepted" });
104
- } else {
105
- this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
106
- log("wake_delivery", { ...fields, result: "http_rejected", status: response.status });
107
- }
241
+ // Acceptance does not consume mail or reset uncertainty: only real inbox ACK retires intent.
242
+ log("wake_delivery", {
243
+ ...fields,
244
+ result: response.ok ? "accepted" : "http_rejected",
245
+ status: response.status,
246
+ });
108
247
  } catch {
109
- this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
110
248
  if (!this.controller.signal.aborted) {
111
249
  log("wake_delivery", { ...fields, result: "transport_unconfirmed" });
112
250
  }
113
251
  }
114
252
  }
115
253
 
116
- async stop(): Promise<void> {
117
- this.controller.abort();
118
- clearTimeout(this.timer);
119
- await this.running;
120
- }
121
- }
122
-
123
- export class WakeDeliveries {
124
- private readonly stateDir: string | undefined;
125
- private readonly allowed: (identity: Identity) => boolean;
126
- private readonly deliveries = new Map<
127
- string,
128
- { readonly identity: Identity; readonly mailbox: Mailbox; readonly worker: WakeDelivery }
129
- >();
130
- private readonly cleanup = new Set<Promise<unknown>>();
131
-
132
- constructor(stateDir: string | undefined, allowed: (identity: Identity) => boolean) {
133
- this.stateDir = stateDir;
134
- this.allowed = allowed;
135
- }
136
-
137
- session(identity: Identity, expires: number): void {
138
- if (
139
- !identity.wake ||
140
- this.stateDir === undefined ||
141
- this.deliveries.has(identity.fingerprint256)
142
- ) {
143
- return;
144
- }
145
- const mailbox = new Mailbox(this.stateDir, identity.fingerprint256, true);
146
- const worker = new WakeDelivery(
147
- identity.name,
148
- mailbox,
149
- identity.wake,
150
- () => this.allowed(identity) && expires > Date.now(),
151
- );
152
- this.deliveries.set(identity.fingerprint256, { identity, mailbox, worker });
153
- worker.start();
154
- }
155
-
156
254
  drop(fp: string): void {
157
255
  const delivery = this.deliveries.get(fp);
158
- if (!delivery) {
159
- return;
160
- }
161
256
  this.deliveries.delete(fp);
162
- const cleanup = delivery.worker
163
- .stop()
164
- .then(() => delivery.mailbox.close())
165
- .catch(() => {
166
- log("wake_delivery", { name: delivery.identity.name, result: "cleanup_failed" });
167
- })
168
- .finally(() => {
169
- this.cleanup.delete(cleanup);
170
- });
171
- this.cleanup.add(cleanup);
257
+ delivery?.controller.abort();
258
+ delivery?.mailbox.close();
259
+ // Membership removal alone is not an ACK. Reload restores proof-backed replacements first.
260
+ this.changed(false);
172
261
  }
173
262
 
174
263
  reload(): void {
@@ -177,12 +266,17 @@ export class WakeDeliveries {
177
266
  this.drop(fp);
178
267
  }
179
268
  }
269
+ this.changed(false);
180
270
  }
181
271
 
182
272
  async stop(): Promise<void> {
183
- for (const fp of this.deliveries.keys()) {
184
- this.drop(fp);
273
+ this.controller.abort();
274
+ clearTimeout(this.timer);
275
+ await this.running;
276
+ for (const delivery of this.deliveries.values()) {
277
+ delivery.mailbox.close();
185
278
  }
186
- await Promise.all(this.cleanup);
279
+ this.deliveries.clear();
280
+ this.state?.close();
187
281
  }
188
282
  }
@@ -8,14 +8,23 @@ import { getBrokerSocketPath } from "./broker/paths.ts";
8
8
  import { loadConfig, sameListener, type Config, type Identity } from "./bridge-config.ts";
9
9
  import { ASK_TIMEOUT, Fault, fingerprint, log } from "./bridge-protocol.ts";
10
10
  import { Session } from "./bridge-session.ts";
11
- import { operationFor, routeFor, type Operation } from "./bridge-routes.ts";
12
- import { reply } from "./bridge-http.ts";
13
- import { Launcher, sameIdentity } from "./bridge-launch.ts";
11
+ import { operationFor, routeFor } from "./bridge-routes.ts";
12
+ import { reply, replyFault } from "./bridge-http.ts";
13
+ import { sameIdentity } from "./bridge-launch.ts";
14
+ import { BridgeOperations } from "./bridge-operations.ts";
15
+ import { authenticatedCertificateChain } from "./native-control-auth.ts";
14
16
  import { PersistentPresence } from "./bridge-presence.ts";
15
17
  import { WakeDeliveries } from "./bridge-wake.ts";
16
18
  import { spawnBrokerIfNeeded } from "./broker/spawn.ts";
17
19
 
18
20
  const MAX_CONNECTIONS = 128;
21
+ const LONG_ROUTES = new Set([
22
+ "POST /v1/start",
23
+ "POST /v1/spawn",
24
+ "POST /v1/resume",
25
+ "GET /v1/transcript",
26
+ "POST /v1/transcript",
27
+ ]);
19
28
 
20
29
  function validCertificateDates(starts: number, expires: number): boolean {
21
30
  return (
@@ -37,7 +46,7 @@ class Bridge {
37
46
  private readonly configPath: string;
38
47
  private readonly sessions = new Map<string, Session>();
39
48
  private readonly losses = new Map<string, number>();
40
- private readonly launcher = new Launcher();
49
+ private readonly operations: BridgeOperations;
41
50
  private readonly sockets = new Set<Socket>();
42
51
  private readonly connections = new Map<TLSSocket, Connection>();
43
52
  private readonly server: Server;
@@ -49,11 +58,13 @@ class Bridge {
49
58
  constructor(config: Config, configPath: string) {
50
59
  this.config = config;
51
60
  this.configPath = configPath;
61
+ this.operations = new BridgeOperations(config.stateDir);
52
62
  this.deliveries = new WakeDeliveries(config.stateDir, (identity) => this.allowed(identity));
53
63
  if (config.stateDir !== undefined) {
54
64
  this.presence = new PersistentPresence({
55
65
  stateDir: config.stateDir,
56
66
  clients: () => this.config.clients,
67
+ restored: (identity, expires) => this.deliveries.session(identity, expires),
57
68
  connect: async (identity, expires) => {
58
69
  await spawnBrokerIfNeeded(process.execPath, []);
59
70
  if (!this.allowed(identity)) {
@@ -143,6 +154,7 @@ class Bridge {
143
154
  {
144
155
  stateDir: this.config.stateDir,
145
156
  notifications: identity.wake !== undefined,
157
+ mailboxChanged: () => this.deliveries.changed(),
146
158
  allowed: (candidate) => this.allowed(candidate),
147
159
  lost: (fp, count) => this.lost(fp, count),
148
160
  losses: (fp) => this.losses.get(fp) ?? 0,
@@ -245,12 +257,12 @@ class Bridge {
245
257
  }
246
258
  this.config = next;
247
259
  this.revokeRemoved();
248
- this.launcher.revoke(next.clients).catch(() => log("launch_cleanup", { result: "failed" }));
260
+ this.operations.revoke(next.clients).catch(() => log("launch_cleanup", { result: "failed" }));
249
261
  log("allowlist_reloaded", { result: "accepted", clients: next.clients.size });
250
262
  } catch {
251
263
  this.config = { ...this.config, clients: new Map() };
252
264
  this.deliveries.reload();
253
- this.launcher
265
+ this.operations
254
266
  .revoke(this.config.clients)
255
267
  .catch(() => log("launch_cleanup", { result: "failed" }));
256
268
  for (const session of this.sessions.values()) {
@@ -289,6 +301,9 @@ class Bridge {
289
301
  this.losses.delete(fp);
290
302
  }
291
303
  }
304
+ // Retire stale sessions before publishing replacement delivery membership for the same pin.
305
+ this.presence?.restoreAll();
306
+ this.deliveries.changed();
292
307
  }
293
308
  private stop(): void {
294
309
  this.shutdown().catch(() => {
@@ -312,7 +327,7 @@ class Bridge {
312
327
  await Promise.all([
313
328
  ...this.handlers,
314
329
  ...sessions.map((session) => session.settled()),
315
- this.launcher.shutdown(),
330
+ this.operations.shutdown(),
316
331
  this.presence?.stop(),
317
332
  this.deliveries.stop(),
318
333
  ]);
@@ -321,6 +336,7 @@ class Bridge {
321
336
  private admission(req: IncomingMessage): {
322
337
  readonly identity: Identity;
323
338
  readonly expires: number;
339
+ readonly certificate: string;
324
340
  } {
325
341
  const socket = req.socket;
326
342
  const connection = socket instanceof TLSSocket ? this.connections.get(socket) : undefined;
@@ -339,7 +355,11 @@ class Bridge {
339
355
  this.drop(connection.fingerprint, fault);
340
356
  throw fault;
341
357
  }
342
- return { identity, expires: connection.expires };
358
+ return {
359
+ identity,
360
+ expires: connection.expires,
361
+ certificate: authenticatedCertificateChain(socket),
362
+ };
343
363
  }
344
364
  private async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
345
365
  const requestId = randomUUID();
@@ -348,8 +368,11 @@ class Bridge {
348
368
  let controller: AbortController | undefined;
349
369
  let deadline: NodeJS.Timeout | undefined;
350
370
  let peer: string | undefined;
371
+ let readOnlyInbox = false;
372
+ const cancel = (fault: Readonly<Fault>) =>
373
+ readOnlyInbox ? controller?.abort(fault) : session?.close(fault);
351
374
  const abort = () =>
352
- session?.close(
375
+ cancel(
353
376
  new Fault(499, "client_aborted", "HTTP client disconnected; delivery may be unknown."),
354
377
  );
355
378
  const onClose = () => {
@@ -361,7 +384,7 @@ class Bridge {
361
384
  clearTimeout(deadline);
362
385
  deadline = setTimeout(
363
386
  () =>
364
- session?.close(
387
+ cancel(
365
388
  new Fault(
366
389
  504,
367
390
  "request_timeout",
@@ -372,23 +395,27 @@ class Bridge {
372
395
  );
373
396
  };
374
397
  try {
375
- const { identity, expires } = this.admission(req);
398
+ const { identity, expires, certificate } = this.admission(req);
376
399
  const route = routeFor(req);
400
+ readOnlyInbox = route === "GET /v1/inbox";
377
401
  session = this.session(identity, expires);
378
402
  controller = session.begin();
379
403
  const signal = controller.signal;
380
404
  req.once("aborted", abort);
381
405
  res.once("close", onClose);
382
406
  const duration = route === "POST /v1/ask" ? ASK_TIMEOUT + 15000 : 15000;
383
- setDeadline(route === "POST /v1/start" ? 75000 : duration);
407
+ setDeadline(LONG_ROUTES.has(route) ? 75000 : duration);
384
408
  const operation = await operationFor(req, route, signal);
385
409
  if (operation.route === "POST /v1/ask") {
386
410
  setDeadline(operation.timeoutMs + 15000);
411
+ } else if (operation.route === "GET /v1/transcript") {
412
+ setDeadline(75000);
387
413
  }
388
- const data = await this.execute(operation, session, {
414
+ const data = await this.operations.execute(operation, session, {
389
415
  signal,
390
416
  requestId,
391
417
  expires,
418
+ certificate,
392
419
  onPeer: (id) => {
393
420
  peer = id;
394
421
  },
@@ -402,10 +429,7 @@ class Bridge {
402
429
  ? error
403
430
  : new Fault(503, "broker_unavailable", "Local broker operation failed.");
404
431
  log("request", { ...session?.fields(), requestId, peer, result: fault.code });
405
- reply(res, requestId, fault.status, {
406
- ok: false,
407
- error: { code: fault.code, message: fault.message, ...fault.details },
408
- });
432
+ replyFault(res, requestId, fault);
409
433
  } finally {
410
434
  clearTimeout(deadline);
411
435
  req.off("aborted", abort);
@@ -415,38 +439,6 @@ class Bridge {
415
439
  }
416
440
  }
417
441
  }
418
- private async execute(
419
- operation: Operation,
420
- session: Readonly<Session>,
421
- context: {
422
- readonly signal: AbortSignal;
423
- readonly requestId: string;
424
- readonly expires: number;
425
- readonly onPeer: (id: string) => void;
426
- },
427
- ) {
428
- switch (operation.route) {
429
- case "POST /v1/start":
430
- return this.launcher.start(operation.name, {
431
- identity: session.identity,
432
- expires: context.expires,
433
- signal: context.signal,
434
- peers: (signal) => session.connectedPeers(signal),
435
- });
436
- case "GET /v1/sessions":
437
- return this.launcher.list(session.identity);
438
- case "POST /v1/stop":
439
- return this.launcher.stop(session.identity, operation.sessionId);
440
- case "GET /v1/list":
441
- case "GET /v1/inbox":
442
- case "POST /v1/register":
443
- case "POST /v1/ask":
444
- case "POST /v1/send":
445
- case "POST /v1/reply":
446
- case "POST /v1/ack":
447
- return session.execute(operation, context);
448
- }
449
- }
450
442
  }
451
443
 
452
444
  function main(): void {
@@ -1,6 +1,7 @@
1
1
  import { EventEmitter } from "events";
2
2
  import type net from "net";
3
3
  import { randomUUID } from "crypto";
4
+ import type { NativeControlEnvelope } from "../native-control-protocol.ts";
4
5
  import { writeMessage } from "./framing.ts";
5
6
  import { BrokerConnection, connectBrokerSocket } from "./connection.ts";
6
7
  import { SnapshotReader } from "./snapshot-reader.ts";
@@ -51,6 +52,7 @@ interface SendOptions {
51
52
  readonly messageId?: string;
52
53
  readonly human?: HumanMessageOrigin;
53
54
  readonly topic?: TopicUpdate;
55
+ readonly control?: NativeControlEnvelope;
54
56
  }
55
57
 
56
58
  function toError(error: unknown): Error {
@@ -405,6 +407,7 @@ export class IntercomClient extends EventEmitter {
405
407
  expectsReply: options.expectsReply,
406
408
  ...(options.human ? { human: options.human } : {}),
407
409
  ...(options.topic ? { topic: options.topic } : {}),
410
+ ...(options.control ? { control: options.control } : {}),
408
411
  delivery: messageDelivery(options),
409
412
  queueMode: options.queueMode,
410
413
  threadId: options.threadId,
@@ -1,4 +1,3 @@
1
- import { createHash } from "node:crypto";
2
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
2
  import { IntercomClient } from "./broker/client.ts";
4
3
  import type { IntercomTransport } from "./transport.ts";
@@ -8,7 +7,7 @@ import type { IntercomLifecycle } from "./lifecycle.ts";
8
7
  import type { IntercomTopics } from "./topics.ts";
9
8
  import type { Message, SessionInfo, TopicChange } from "./types.ts";
10
9
  import { asError, errorMessage } from "./validation.ts";
11
- import { formatTargetOptions, resolveSessionTarget } from "./session-targets.ts";
10
+ import { formatTargetOptions, resolveSessionTarget, nativePeerId } from "./session-targets.ts";
12
11
 
13
12
  export type ConnectionReason = "startup" | "background" | "tool" | "overlay" | "peer-awareness";
14
13
  type Lifecycle = Readonly<
@@ -233,7 +232,7 @@ export class IntercomConnection {
233
232
  }
234
233
  await next.connect(
235
234
  await this.lifecycle.registration(this.hooks.pendingAsks()),
236
- `pi-${createHash("sha256").update(sessionId).digest("hex").slice(0, 32)}`,
235
+ nativePeerId(sessionId),
237
236
  );
238
237
  if (!this.lifecycle.live(ctx, generation)) {
239
238
  await next.disconnect();