@fitchmultz/pi-subagents 0.50.0 → 0.52.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 (180) hide show
  1. package/CHANGELOG.md +63 -2
  2. package/README.md +36 -5
  3. package/dist/agent/child-runtime.js +80 -0
  4. package/dist/agent/contracts.js +15 -24
  5. package/dist/agent/host.js +3 -16
  6. package/dist/agent/index.js +212 -192
  7. package/dist/agent/journal-tail.js +115 -156
  8. package/dist/agent/launch.js +20 -8
  9. package/dist/agent/legacy.js +65 -23
  10. package/dist/agent/notifications.js +138 -47
  11. package/dist/agent/observation-state.js +246 -0
  12. package/dist/agent/observation-view.js +258 -0
  13. package/dist/agent/observation.js +350 -0
  14. package/dist/agent/observe-card.js +243 -0
  15. package/dist/agent/observe-chrome.js +85 -0
  16. package/dist/agent/observe-client.js +285 -0
  17. package/dist/agent/observe-layout.js +65 -0
  18. package/dist/agent/observe-reading.js +84 -0
  19. package/dist/agent/observe-screen.js +400 -96
  20. package/dist/agent/observe-transcript.js +382 -0
  21. package/dist/agent/observe.js +250 -43
  22. package/dist/agent/parking.js +253 -0
  23. package/dist/agent/process.js +228 -28
  24. package/dist/agent/receipts.js +92 -20
  25. package/dist/agent/runner.js +229 -126
  26. package/dist/agent/shell.js +2 -1
  27. package/dist/agent/storage.js +47 -12
  28. package/dist/agent/tool-schemas.js +20 -0
  29. package/dist/agent/typebox.js +10 -5
  30. package/dist/agent/usage.js +0 -115
  31. package/dist/agent/view.js +46 -16
  32. package/dist/agent/wake.js +99 -0
  33. package/dist/build-info.js +1 -1
  34. package/dist/intercom-client.d.ts +26 -0
  35. package/dist/intercom-client.js +45 -0
  36. package/dist/pi-intercom/agent-dir.d.ts +1 -0
  37. package/dist/pi-intercom/bridge-session.js +2 -1
  38. package/dist/pi-intercom/broker/broker.js +62 -43
  39. package/dist/pi-intercom/broker/client-last.d.ts +24 -0
  40. package/dist/pi-intercom/broker/client-last.js +42 -5
  41. package/dist/pi-intercom/broker/client-park.d.ts +9 -0
  42. package/dist/pi-intercom/broker/client-park.js +21 -0
  43. package/dist/pi-intercom/broker/client.d.ts +59 -0
  44. package/dist/pi-intercom/broker/client.js +55 -29
  45. package/dist/pi-intercom/broker/connection.d.ts +29 -0
  46. package/dist/pi-intercom/broker/framing.d.ts +17 -0
  47. package/dist/pi-intercom/broker/framing.js +10 -7
  48. package/dist/pi-intercom/broker/guard.js +23 -0
  49. package/dist/pi-intercom/broker/last.d.ts +30 -0
  50. package/dist/pi-intercom/broker/last.js +87 -40
  51. package/dist/pi-intercom/broker/parked.js +107 -0
  52. package/dist/pi-intercom/broker/paths.d.ts +13 -0
  53. package/dist/pi-intercom/broker/paths.js +35 -12
  54. package/dist/pi-intercom/broker/pid.js +5 -1
  55. package/dist/pi-intercom/broker/server-connection.js +20 -1
  56. package/dist/pi-intercom/broker/snapshot-reader.d.ts +9 -0
  57. package/dist/pi-intercom/broker/snapshots.d.ts +29 -0
  58. package/dist/pi-intercom/broker/targets.d.ts +9 -0
  59. package/dist/pi-intercom/connection.js +71 -20
  60. package/dist/pi-intercom/identity.js +6 -5
  61. package/dist/pi-intercom/inbound-delivery.js +16 -60
  62. package/dist/pi-intercom/inbound-journal.js +83 -6
  63. package/dist/pi-intercom/inbound-record.js +18 -1
  64. package/dist/pi-intercom/inbound.js +22 -37
  65. package/dist/pi-intercom/index.js +41 -0
  66. package/dist/pi-intercom/last-message.js +4 -16
  67. package/dist/pi-intercom/lifecycle.js +88 -3
  68. package/dist/pi-intercom/outbound.js +5 -15
  69. package/dist/pi-intercom/reply-tracker.js +7 -6
  70. package/dist/pi-intercom/reply-wait.js +2 -2
  71. package/dist/pi-intercom/session-targets.d.ts +24 -0
  72. package/dist/pi-intercom/tool-registration.js +4 -1
  73. package/dist/pi-intercom/tool-results.js +5 -7
  74. package/dist/pi-intercom/tool-schemas.js +12 -26
  75. package/dist/pi-intercom/types.d.ts +212 -0
  76. package/dist/pi-intercom/types.js +54 -3
  77. package/dist/pi-intercom/validation.d.ts +12 -0
  78. package/dist/pi-intercom/validation.js +3 -0
  79. package/dist/shared/bounded-json.js +151 -0
  80. package/dist/shared/compact-cards.js +66 -0
  81. package/dist/shared/journal-frames.js +3 -6
  82. package/dist/shared/json-projection.js +267 -129
  83. package/dist/shared/native-journal.js +123 -56
  84. package/dist/shared/parking.js +12 -0
  85. package/dist/shared/post-exit-stdio-guard.js +120 -7
  86. package/dist/shared/tui-action.js +29 -0
  87. package/dist/shared/unknown.d.ts +9 -0
  88. package/docs/code-quality.md +5 -3
  89. package/docs/development.md +42 -3
  90. package/docs/intercom-bridge.md +9 -4
  91. package/docs/intercom.md +228 -7
  92. package/docs/performance.md +118 -14
  93. package/docs/reference.md +168 -20
  94. package/docs/retired-history-maintenance.md +252 -0
  95. package/package.json +28 -15
  96. package/scripts/archive-retired-history.mjs +464 -0
  97. package/scripts/build.mjs +48 -28
  98. package/scripts/measure-size.mjs +1 -1
  99. package/scripts/native-package-smoke.mjs +153 -12
  100. package/scripts/package-smoke.mjs +80 -11
  101. package/scripts/prepare.mjs +2 -2
  102. package/scripts/real-pi-live.mjs +377 -0
  103. package/scripts/real-pi-processes.mjs +6 -1
  104. package/scripts/real-pi-smoke.mjs +6 -282
  105. package/scripts/run-tests.mjs +4 -2
  106. package/scripts/typescript.mjs +35 -0
  107. package/skills/pi-intercom/SKILL.md +2 -1
  108. package/skills/pi-subagents/SKILL.md +23 -5
  109. package/src/agent/child-runtime.ts +105 -0
  110. package/src/agent/contracts.ts +18 -34
  111. package/src/agent/host.ts +39 -20
  112. package/src/agent/index.ts +234 -207
  113. package/src/agent/journal-tail.ts +141 -153
  114. package/src/agent/launch.ts +33 -9
  115. package/src/agent/legacy.ts +85 -31
  116. package/src/agent/notifications.ts +192 -67
  117. package/src/agent/observation-state.ts +277 -0
  118. package/src/agent/observation-view.ts +267 -0
  119. package/src/agent/observation.ts +409 -0
  120. package/src/agent/observe-card.ts +304 -0
  121. package/src/agent/observe-chrome.ts +170 -0
  122. package/src/agent/observe-client.ts +318 -0
  123. package/src/agent/observe-layout.ts +79 -0
  124. package/src/agent/observe-reading.ts +97 -0
  125. package/src/agent/observe-screen.ts +443 -129
  126. package/src/agent/observe-transcript.ts +434 -0
  127. package/src/agent/observe.ts +304 -71
  128. package/src/agent/parking.ts +341 -0
  129. package/src/agent/process.ts +280 -37
  130. package/src/agent/receipts.ts +114 -24
  131. package/src/agent/runner.ts +227 -149
  132. package/src/agent/shell.ts +2 -1
  133. package/src/agent/storage.ts +44 -11
  134. package/src/agent/tool-schemas.ts +33 -0
  135. package/src/agent/typebox.ts +12 -7
  136. package/src/agent/usage.ts +0 -130
  137. package/src/agent/view.ts +70 -20
  138. package/src/agent/wake.ts +107 -0
  139. package/src/intercom-client.ts +108 -0
  140. package/src/pi-intercom/bridge-session.ts +2 -2
  141. package/src/pi-intercom/broker/broker.ts +64 -53
  142. package/src/pi-intercom/broker/client-last.ts +65 -12
  143. package/src/pi-intercom/broker/client-park.ts +23 -0
  144. package/src/pi-intercom/broker/client.ts +63 -45
  145. package/src/pi-intercom/broker/framing.ts +11 -8
  146. package/src/pi-intercom/broker/guard.ts +33 -0
  147. package/src/pi-intercom/broker/last.ts +131 -43
  148. package/src/pi-intercom/broker/parked.ts +130 -0
  149. package/src/pi-intercom/broker/paths.ts +39 -12
  150. package/src/pi-intercom/broker/pid.ts +6 -1
  151. package/src/pi-intercom/broker/server-connection.ts +27 -2
  152. package/src/pi-intercom/connection.ts +103 -39
  153. package/src/pi-intercom/identity.ts +8 -5
  154. package/src/pi-intercom/inbound-delivery.ts +20 -66
  155. package/src/pi-intercom/inbound-journal.ts +103 -7
  156. package/src/pi-intercom/inbound-record.ts +21 -4
  157. package/src/pi-intercom/inbound.ts +32 -49
  158. package/src/pi-intercom/index.ts +44 -0
  159. package/src/pi-intercom/last-message.ts +11 -20
  160. package/src/pi-intercom/lifecycle.ts +117 -14
  161. package/src/pi-intercom/outbound.ts +7 -16
  162. package/src/pi-intercom/reply-tracker.ts +8 -6
  163. package/src/pi-intercom/reply-wait.ts +3 -2
  164. package/src/pi-intercom/runtime-types.ts +0 -1
  165. package/src/pi-intercom/tool-registration.ts +4 -1
  166. package/src/pi-intercom/tool-results.ts +10 -9
  167. package/src/pi-intercom/tool-schemas.ts +12 -44
  168. package/src/pi-intercom/types.ts +115 -4
  169. package/src/pi-intercom/validation.ts +4 -0
  170. package/src/shared/bounded-json.ts +162 -0
  171. package/src/shared/compact-cards.ts +118 -0
  172. package/src/shared/journal-frames.ts +3 -19
  173. package/src/shared/json-projection.ts +267 -147
  174. package/src/shared/native-journal.ts +157 -75
  175. package/src/shared/parking.ts +22 -0
  176. package/src/shared/post-exit-stdio-guard.ts +165 -7
  177. package/src/shared/tui-action.ts +39 -0
  178. package/tsconfig.client.json +8 -0
  179. package/tsconfig.json +1 -0
  180. package/src/shared/stream-json.d.ts +0 -10
@@ -7,11 +7,12 @@ import { getBrokerRuntimeDir, prepareBrokerSocketPath } from "./paths.ts";
7
7
  import { brokerPidRecord } from "./pid.ts";
8
8
  import { assertBrokerCanStart } from "./spawn.ts";
9
9
  import { LastMessageRelay } from "./last.ts";
10
- import { assertMainParent, canSeeSession, findSessions } from "./targets.ts";
11
- import { acceptBrokerConnection } from "./server-connection.ts";
10
+ import { canSeeSession, findSessions } from "./targets.ts";
11
+ import { ParkedSessions, reachable, type ParkablePeer } from "./parked.ts";
12
+ import { acceptBrokerConnection, registeredSessionInfo } from "./server-connection.ts";
13
+ import { guardedDeliveryError } from "./guard.ts";
12
14
  import {
13
15
  compactTopicMessage,
14
- isSessionRegistration,
15
16
  normalizeMessage,
16
17
  normalizeSessionInfo,
17
18
  type SessionInfo,
@@ -35,12 +36,12 @@ import {
35
36
  const PID_PATH = join(getBrokerRuntimeDir(), "broker.pid");
36
37
  const REPLACE_DELIVERY_DELAY_MS = 1500;
37
38
 
38
- interface ConnectedSession {
39
- readonly socket: net.Socket;
39
+ interface ConnectedSession extends ParkablePeer {
40
40
  info: SessionInfo;
41
41
  topics: TopicSnapshot;
42
42
  readonly topicFrames: boolean;
43
43
  readonly lastMessages: boolean;
44
+ readonly receiptReads: boolean;
44
45
  }
45
46
 
46
47
  interface PendingReplaceDelivery {
@@ -53,6 +54,7 @@ interface PendingReplaceDelivery {
53
54
  class IntercomBroker {
54
55
  private readonly sessions = new Map<string, ConnectedSession>();
55
56
  private readonly lastMessages = new LastMessageRelay(this.sessions);
57
+ private readonly parking = new ParkedSessions(this.sessions, this.deliverMessage.bind(this));
56
58
  private readonly pendingReplaceDeliveries = new Map<string, PendingReplaceDelivery>();
57
59
  private readonly server: net.Server;
58
60
  private shutdownTimer: NodeJS.Timeout | null = null;
@@ -95,7 +97,7 @@ class IntercomBroker {
95
97
 
96
98
  this.shutdownTimer = setTimeout(() => {
97
99
  this.shutdownTimer = null;
98
- if (this.sessions.size === 0) {
100
+ if (this.parking.exited) {
99
101
  console.log("No sessions connected, shutting down");
100
102
  this.shutdown();
101
103
  }
@@ -129,7 +131,7 @@ class IntercomBroker {
129
131
  }
130
132
  switch (msg.type) {
131
133
  case "unregister":
132
- this.removeSession(currentId);
134
+ this.removeSession(currentId, socket);
133
135
  setId(null);
134
136
  break;
135
137
  case "list":
@@ -139,7 +141,8 @@ class IntercomBroker {
139
141
  this.send(socket, msg, currentId);
140
142
  break;
141
143
  case "last_request":
142
- this.lastMessages.request(currentId, msg);
144
+ // Reads never spawn a parked child.
145
+ this.lastMessages.request(currentId, msg, (id) => this.parking.reader(id));
143
146
  break;
144
147
  case "last_response":
145
148
  this.lastMessages.respond(currentId, msg);
@@ -147,31 +150,30 @@ class IntercomBroker {
147
150
  case "presence":
148
151
  this.presence(msg, currentId);
149
152
  break;
153
+ case "park":
154
+ case "unpark":
155
+ this.parking.park(socket, currentId, msg.type === "park");
156
+ break;
150
157
  default:
151
158
  throw new Error(`Unknown client message type: ${msg.type}`);
152
159
  }
153
160
  }
154
161
 
155
162
  private register(socket: net.Socket, msg: UnknownRecord, setId: (id: string) => void): void {
156
- if (!isSessionRegistration(msg.session)) {
157
- throw new Error("Invalid register message");
158
- }
159
- assertMainParent(msg.session, this.sessions);
160
- const requestedId =
161
- typeof msg.requestedId === "string" && /^[a-zA-Z0-9_-]{8,80}$/.test(msg.requestedId)
162
- ? msg.requestedId
163
- : undefined;
164
- const id =
165
- requestedId !== undefined && !this.sessions.has(requestedId) ? requestedId : randomUUID();
166
- const info: SessionInfo = { ...msg.session, id, lastSeen: msg.session.lastSeen ?? Date.now() };
163
+ const registered = registeredSessionInfo(msg, this.sessions);
164
+ const { id, resumed } = this.parking.claim(msg.requestedId, registered);
165
+ const info: SessionInfo = { ...registered, id };
167
166
  snapshotFrames(randomUUID(), [info], true);
168
167
  const topicFrames = msg.topicFrames === true;
169
168
  this.sessions.set(id, {
170
169
  socket,
171
170
  info: messageSender(info),
172
- topics: new TopicSnapshot(info),
171
+ // A woken generation registers no topics; the parked identity's owner keeps them.
172
+ topics: resumed?.topics ?? new TopicSnapshot(info),
173
173
  topicFrames,
174
174
  lastMessages: msg.lastMessages === true,
175
+ parking: msg.parking === true,
176
+ receiptReads: msg.receiptReads === 1,
175
177
  });
176
178
  setId(id);
177
179
  if (this.shutdownTimer) {
@@ -182,19 +184,25 @@ class IntercomBroker {
182
184
  type: "registered",
183
185
  sessionId: id,
184
186
  lastMessages: true,
187
+ parking: msg.parking === true,
188
+ receiptReads: 1,
189
+ ...(msg.guardedDelivery === 1 ? { guardedDelivery: 1 } : {}),
185
190
  ...(topicFrames ? { topicsSupported: true, topicFrames: true } : {}),
186
191
  });
192
+ this.parking.registered(id, resumed);
187
193
  }
188
194
 
189
- private removeSession(sessionId: string): void {
195
+ private removeSession(sessionId: string, socket: net.Socket): void {
190
196
  const session = this.sessions.get(sessionId);
191
- if (!session) {
197
+ if (session?.socket !== socket) {
192
198
  return;
193
199
  }
194
- this.sessions.delete(sessionId);
195
200
  this.lastMessages.disconnected(sessionId);
196
- this.clearPendingReplaceDeliveries(sessionId);
197
- this.broadcast({ type: "session_left", sessionId }, session.info);
201
+ if (!this.parking.closed(session)) {
202
+ this.sessions.delete(sessionId);
203
+ this.clearPendingReplaceDeliveries(sessionId);
204
+ this.broadcast({ type: "session_left", sessionId }, session.info);
205
+ }
198
206
  this.scheduleShutdownCheck();
199
207
  }
200
208
 
@@ -213,7 +221,7 @@ class IntercomBroker {
213
221
  continue;
214
222
  }
215
223
  const message = publicationMessage(publication, subscription.awaitRelease === true);
216
- const error = this.validateDeliveryPayload(from, message, target.topicFrames);
224
+ const error = this.validateDeliveryPayload(from, message, target);
217
225
  if (error !== null) {
218
226
  throw new Error(error);
219
227
  }
@@ -296,6 +304,15 @@ class IntercomBroker {
296
304
  const targets = findSessions(this.sessions, msg.to, currentId);
297
305
  const target = targets[0];
298
306
  if (targets.length === 1) {
307
+ const guardError = guardedDeliveryError(this.sessions, msg.to, message);
308
+ if (guardError !== null) {
309
+ writeMessage(socket, {
310
+ type: "delivery_failed",
311
+ messageId: message.id,
312
+ reason: guardError,
313
+ });
314
+ return;
315
+ }
299
316
  const fromSession = this.sessions.get(currentId);
300
317
  if (!fromSession) {
301
318
  writeMessage(socket, {
@@ -343,11 +360,15 @@ class IntercomBroker {
343
360
  "lastIntercomActivity",
344
361
  "subscriptions",
345
362
  "topics",
363
+ "applicationGuard",
346
364
  ]) {
347
365
  if (msg[key] !== undefined) {
348
366
  update[key] = msg[key];
349
367
  }
350
368
  }
369
+ if (msg.applicationGuard === null) {
370
+ delete update.applicationGuard;
371
+ }
351
372
  const nextInfo = normalizeSessionInfo(update);
352
373
  if (!nextInfo) {
353
374
  throw new Error("Invalid presence update");
@@ -366,22 +387,16 @@ class IntercomBroker {
366
387
  message: Message,
367
388
  ): SendResult {
368
389
  const target = this.sessions.get(toId);
369
- if (!target) {
370
- return {
371
- id: message.id,
372
- accepted: false,
373
- delivered: false,
374
- reason: "Recipient disconnected before delivery",
375
- };
376
- }
377
- if (shouldReplaceDelivery(message, target.info)) {
390
+ if (target && target.parked === undefined && shouldReplaceDelivery(message, target.info)) {
378
391
  return this.queueReplaceDelivery(fromId, toId, from, message);
379
392
  }
380
393
  const reason = this.deliverMessage(toId, from, message);
394
+ const queued = reason === null && target?.parked !== undefined;
381
395
  return {
382
396
  id: message.id,
383
397
  accepted: reason === null,
384
- delivered: reason === null,
398
+ delivered: reason === null && !queued,
399
+ ...(queued ? { queued } : {}),
385
400
  ...(reason !== null ? { reason } : {}),
386
401
  };
387
402
  }
@@ -410,11 +425,7 @@ class IntercomBroker {
410
425
  from: SessionInfo,
411
426
  message: Message,
412
427
  ): SendResult {
413
- const validationError = this.validateDeliveryPayload(
414
- from,
415
- message,
416
- this.sessions.get(toId)?.topicFrames === true,
417
- );
428
+ const validationError = this.validateDeliveryPayload(from, message, this.sessions.get(toId));
418
429
  if (validationError !== null) {
419
430
  return { id: message.id, accepted: false, delivered: false, reason: validationError };
420
431
  }
@@ -450,33 +461,33 @@ class IntercomBroker {
450
461
  }
451
462
  }
452
463
 
464
+ /** One admission rule; retained input is checked as the uncompacted envelope any later
465
+ * transport generation accepts. */
453
466
  private validateDeliveryPayload(
454
467
  from: SessionInfo,
455
468
  message: Message,
456
- topicFrames: boolean,
469
+ target: ConnectedSession | undefined,
457
470
  ): string | null {
471
+ const compact = target?.parked === undefined && target?.topicFrames === true;
458
472
  return (
459
473
  validateIntercomMessageSize({
460
474
  type: "message",
461
475
  from: messageSender(from),
462
- message: topicFrames ? compactTopicMessage(message) : message,
476
+ message: compact ? compactTopicMessage(message) : message,
463
477
  })?.message ?? null
464
478
  );
465
479
  }
466
480
 
467
481
  private deliverMessage(toId: string, from: SessionInfo, message: Message): string | null {
468
482
  const target = this.sessions.get(toId);
469
- if (
470
- !target ||
471
- target.socket.destroyed ||
472
- target.socket.writableEnded ||
473
- !target.socket.writable
474
- ) {
475
- return "Recipient disconnected before delivery";
483
+ const refused =
484
+ guardedDeliveryError(this.sessions, toId, message) ??
485
+ this.validateDeliveryPayload(from, message, target);
486
+ if (refused !== null || this.parking.retain(target, from, message)) {
487
+ return refused;
476
488
  }
477
- const validationError = this.validateDeliveryPayload(from, message, target.topicFrames);
478
- if (validationError !== null) {
479
- return validationError;
489
+ if (!reachable(target)) {
490
+ return "Recipient disconnected before delivery";
480
491
  }
481
492
  this.touchActivity(toId, true);
482
493
  try {
@@ -1,21 +1,63 @@
1
1
  import type net from "node:net";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { errorMessage, type UnknownRecord } from "../../shared/unknown.ts";
4
- import type { LastMessageResult } from "../types.ts";
5
- import { isLastMessageQuery, isLastMessageResult } from "./last.ts";
4
+ import {
5
+ isNativeReadQuery,
6
+ isNativeReadResult,
7
+ isLastMessageResult,
8
+ isReceiptResult,
9
+ type NativeReadQuery,
10
+ type NativeReadResult,
11
+ } from "./last.ts";
12
+ import {
13
+ normalizeSessionInfo,
14
+ type LastMessageQuery,
15
+ type LastMessageResult,
16
+ type ReceiptQuery,
17
+ type ReceiptResult,
18
+ type SessionInfo,
19
+ } from "../types.ts";
6
20
  import { writeMessage } from "./framing.ts";
7
21
 
22
+ /** A peer's read of this session or, for a main, of its exited `parked` child. */
23
+ export function lastRequest(msg: UnknownRecord): {
24
+ readonly requestId: string;
25
+ readonly from: SessionInfo;
26
+ readonly query: NativeReadQuery;
27
+ readonly parked?: SessionInfo;
28
+ } {
29
+ const from = normalizeSessionInfo(msg.from);
30
+ if (typeof msg.requestId !== "string" || !from || !isNativeReadQuery(msg.query)) {
31
+ throw new Error("Invalid last-message request");
32
+ }
33
+ return {
34
+ requestId: msg.requestId,
35
+ from,
36
+ query: msg.query,
37
+ parked: normalizeSessionInfo(msg.parked) ?? undefined,
38
+ };
39
+ }
40
+
8
41
  export class LastMessageRequests {
42
+ /** Negotiated at registration; a failed connection supports neither. */
43
+ lastMessages = false;
44
+ receiptReads = false;
45
+ private readonly timeoutMs: number;
46
+ constructor(timeoutMs: number) {
47
+ this.timeoutMs = timeoutMs;
48
+ }
9
49
  private readonly pending = new Map<
10
50
  string,
11
51
  {
12
- readonly resolve: (result: LastMessageResult) => void;
52
+ readonly resolve: (result: NativeReadResult) => void;
13
53
  readonly reject: (error: Readonly<Error>) => void;
14
54
  readonly timer: NodeJS.Timeout;
15
55
  }
16
56
  >();
17
57
 
18
58
  fail(error: Readonly<Error>): void {
59
+ this.lastMessages = false;
60
+ this.receiptReads = false;
19
61
  for (const pending of this.pending.values()) {
20
62
  clearTimeout(pending.timer);
21
63
  pending.reject(error);
@@ -24,7 +66,7 @@ export class LastMessageRequests {
24
66
  }
25
67
 
26
68
  receive(message: UnknownRecord): void {
27
- if (typeof message.requestId !== "string" || !isLastMessageResult(message.result)) {
69
+ if (typeof message.requestId !== "string" || !isNativeReadResult(message.result)) {
28
70
  throw new Error("Invalid last-message response");
29
71
  }
30
72
  const pending = this.pending.get(message.requestId);
@@ -36,21 +78,32 @@ export class LastMessageRequests {
36
78
  pending.resolve(message.result);
37
79
  }
38
80
 
39
- request(
40
- socket: net.Socket,
41
- to: string,
42
- query: unknown,
43
- timeoutMs: number,
44
- ): Promise<LastMessageResult> {
45
- if (!isLastMessageQuery(query)) {
81
+ async last(socket: net.Socket, to: string, query: LastMessageQuery): Promise<LastMessageResult> {
82
+ const result = await this.request(socket, to, query);
83
+ return isLastMessageResult(result) ? result : { error: "Invalid last-message response" };
84
+ }
85
+ async receipt(socket: net.Socket, to: string, query: ReceiptQuery): Promise<ReceiptResult> {
86
+ const result = await this.request(socket, to, query);
87
+ return isReceiptResult(result) ? result : { error: "Invalid receipt response" };
88
+ }
89
+ private request(socket: net.Socket, to: string, query: unknown): Promise<NativeReadResult> {
90
+ if (!this.lastMessages) {
91
+ return Promise.reject(
92
+ new Error("This broker does not support last-message reads; reload after it exits."),
93
+ );
94
+ }
95
+ if (!isNativeReadQuery(query)) {
46
96
  return Promise.reject(new Error("Invalid last-message query"));
47
97
  }
98
+ if ("messageId" in query && !this.receiptReads) {
99
+ return Promise.reject(new Error("publication_unavailable"));
100
+ }
48
101
  return new Promise((resolve, reject) => {
49
102
  const requestId = randomUUID();
50
103
  const timer = setTimeout(() => {
51
104
  this.pending.delete(requestId);
52
105
  reject(new Error("Last-message read timeout"));
53
- }, timeoutMs);
106
+ }, this.timeoutMs);
54
107
  timer.unref();
55
108
  this.pending.set(requestId, { resolve, reject, timer });
56
109
  try {
@@ -0,0 +1,23 @@
1
+ import type net from "node:net";
2
+ import { writeMessage } from "./framing.ts";
3
+
4
+ /** Parking negotiation and its single outstanding broker acknowledgement. */
5
+ export class ParkRequests {
6
+ supported = false;
7
+ private acknowledged?: (parked: boolean) => void;
8
+ settle(parked: boolean): void {
9
+ this.acknowledged?.(parked);
10
+ }
11
+ /** True once the broker retains later input; false when it cannot or the socket closed. */
12
+ request(socket: () => net.Socket, on: boolean): Promise<boolean> {
13
+ return new Promise((resolve) => {
14
+ this.acknowledged = on && this.supported ? resolve : undefined;
15
+ if (this.supported) {
16
+ writeMessage(socket(), { type: on ? "park" : "unpark" });
17
+ }
18
+ if (this.acknowledged === undefined) {
19
+ resolve(false);
20
+ }
21
+ });
22
+ }
23
+ }
@@ -4,8 +4,9 @@ import { randomUUID } from "crypto";
4
4
  import { writeMessage } from "./framing.ts";
5
5
  import { BrokerConnection, connectBrokerSocket } from "./connection.ts";
6
6
  import { SnapshotReader } from "./snapshot-reader.ts";
7
- import { LastMessageRequests } from "./client-last.ts";
8
- import { isLastMessageQuery, isLastMessageResult } from "./last.ts";
7
+ import { LastMessageRequests, lastRequest } from "./client-last.ts";
8
+ import { ParkRequests } from "./client-park.ts";
9
+ import { isNativeReadResult } from "./last.ts";
9
10
  import {
10
11
  errorMessage,
11
12
  isRecord,
@@ -18,16 +19,16 @@ import {
18
19
  normalizeSessionInfo,
19
20
  type SessionInfo,
20
21
  type Message,
21
- type Attachment,
22
22
  type MessageDelivery,
23
- type QueueMode,
24
- type HumanMessageOrigin,
25
23
  type TopicUpdate,
26
24
  type TopicChange,
27
25
  type SessionSnapshot,
28
26
  type SendResult,
29
27
  type LastMessageQuery,
30
28
  type LastMessageResult,
29
+ type SendOptions,
30
+ type ReceiptQuery,
31
+ type ReceiptResult,
31
32
  } from "../types.ts";
32
33
  export type { SendResult } from "../types.ts";
33
34
 
@@ -43,20 +44,6 @@ export interface IntercomClientOptions {
43
44
  readonly listTimeoutMs?: number;
44
45
  }
45
46
 
46
- interface SendOptions {
47
- readonly text: string;
48
- readonly attachments?: readonly Attachment[];
49
- readonly replyTo?: string;
50
- readonly expectsReply?: boolean;
51
- readonly delivery?: MessageDelivery;
52
- readonly queueMode?: QueueMode;
53
- readonly threadId?: string;
54
- readonly passive?: boolean;
55
- readonly messageId?: string;
56
- readonly human?: HumanMessageOrigin;
57
- readonly topic?: TopicUpdate;
58
- }
59
-
60
47
  function toError(error: unknown): Error {
61
48
  return error instanceof Error ? error : new Error(errorMessage(error));
62
49
  }
@@ -78,8 +65,9 @@ export class IntercomClient extends EventEmitter {
78
65
  private connection: BrokerConnection | null = null;
79
66
  private _sessionId: string | null = null;
80
67
  private _topicsSupported = false;
81
- private lastMessagesSupported = false;
82
- private readonly lastRequests = new LastMessageRequests();
68
+ private readonly parking = new ParkRequests();
69
+ private guardedDeliverySupported = false;
70
+ private readonly lastRequests: LastMessageRequests;
83
71
  private readonly pendingSends = new Map<
84
72
  string,
85
73
  { readonly resolve: (r: SendResult) => void; readonly reject: (e: Readonly<Error>) => void }
@@ -101,6 +89,7 @@ export class IntercomClient extends EventEmitter {
101
89
  super();
102
90
  this.sendTimeoutMs = options.sendTimeoutMs ?? DEFAULT_SEND_TIMEOUT_MS;
103
91
  this.listTimeoutMs = options.listTimeoutMs ?? DEFAULT_LIST_TIMEOUT_MS;
92
+ this.lastRequests = new LastMessageRequests(this.listTimeoutMs);
104
93
  }
105
94
 
106
95
  private failPending(error: Readonly<Error>): void {
@@ -122,6 +111,9 @@ export class IntercomClient extends EventEmitter {
122
111
  get supportsTopics(): boolean {
123
112
  return this.isConnected() && this._topicsSupported;
124
113
  }
114
+ get supportsGuardedDelivery(): boolean {
115
+ return this.isConnected() && this.guardedDeliverySupported;
116
+ }
125
117
  isConnected(): boolean {
126
118
  return (
127
119
  (this._sessionId ?? "").length > 0 &&
@@ -164,7 +156,8 @@ export class IntercomClient extends EventEmitter {
164
156
  this.connection = null;
165
157
  this._sessionId = null;
166
158
  this._topicsSupported = false;
167
- this.lastMessagesSupported = false;
159
+ this.parking.settle(false);
160
+ this.guardedDeliverySupported = false;
168
161
  this.disconnecting = false;
169
162
  this.failPending(error);
170
163
  if (established && !expected) {
@@ -181,6 +174,9 @@ export class IntercomClient extends EventEmitter {
181
174
  requestedId,
182
175
  topicFrames: true,
183
176
  lastMessages: true,
177
+ parking: true,
178
+ receiptReads: 1,
179
+ guardedDelivery: 1,
184
180
  });
185
181
  } catch (error) {
186
182
  if (this.connection === connection) {
@@ -245,11 +241,15 @@ export class IntercomClient extends EventEmitter {
245
241
  case "delivery_failed":
246
242
  this.receiveDelivery(brokerMessage);
247
243
  break;
244
+ case "parked":
245
+ this.parking.settle(true);
246
+ break;
247
+ case "wake":
248
248
  case "session_left":
249
249
  if (typeof brokerMessage.sessionId !== "string") {
250
- throw new Error("Invalid session_left message");
250
+ throw new Error(`Invalid ${type} message`);
251
251
  }
252
- this.emit("session_left", brokerMessage.sessionId);
252
+ this.emit(type, brokerMessage.sessionId);
253
253
  break;
254
254
  default:
255
255
  throw new Error(`Unknown broker message type: ${type}`);
@@ -265,18 +265,18 @@ export class IntercomClient extends EventEmitter {
265
265
  }
266
266
  this._sessionId = msg.sessionId;
267
267
  this._topicsSupported = msg.topicsSupported === true && msg.topicFrames === true;
268
- this.lastMessagesSupported = msg.lastMessages === true;
268
+ this.lastRequests.lastMessages = msg.lastMessages === true;
269
+ this.parking.supported = msg.parking === true;
270
+ this.lastRequests.receiptReads = msg.receiptReads === 1;
271
+ this.guardedDeliverySupported = msg.guardedDelivery === 1;
269
272
  this.connection?.confirmRegistration();
270
273
  this.emit("_registered", { type: "registered", sessionId: msg.sessionId });
271
274
  }
272
275
 
273
276
  private receiveLastRequest(msg: UnknownRecord): void {
274
- const from = normalizeSessionInfo(msg.from);
275
- if (typeof msg.requestId !== "string" || !from || !isLastMessageQuery(msg.query)) {
276
- throw new Error("Invalid last-message request");
277
- }
278
- if (!this.emit("last_request", { requestId: msg.requestId, from, query: msg.query })) {
279
- this.respondLast(msg.requestId, { error: "Recipient has no last-message reader." });
277
+ const request = lastRequest(msg);
278
+ if (!this.emit("last_request", request)) {
279
+ this.respondLast(request.requestId, { error: "Recipient has no last-message reader." });
280
280
  }
281
281
  }
282
282
 
@@ -337,6 +337,10 @@ export class IntercomClient extends EventEmitter {
337
337
  }
338
338
  }
339
339
 
340
+ park(on = true): Promise<boolean> {
341
+ return this.parking.request(() => this.requireActiveSocket(), on);
342
+ }
343
+
340
344
  async disconnect(): Promise<void> {
341
345
  const connection = this.connection;
342
346
  if (!connection) {
@@ -351,20 +355,15 @@ export class IntercomClient extends EventEmitter {
351
355
  return this.requestSessions().then((snapshot) => [...snapshot.sessions]);
352
356
  }
353
357
 
354
- last(to: string, query: LastMessageQuery = {}): Promise<LastMessageResult> {
355
- try {
356
- const socket = this.requireActiveSocket();
357
- if (!this.lastMessagesSupported) {
358
- throw new Error("This broker does not support last-message reads; reload after it exits.");
359
- }
360
- return this.lastRequests.request(socket, to, query, this.listTimeoutMs);
361
- } catch (error) {
362
- return Promise.reject(toError(error));
363
- }
358
+ async last(to: string, query: LastMessageQuery = {}): Promise<LastMessageResult> {
359
+ return await this.lastRequests.last(this.requireActiveSocket(), to, query);
360
+ }
361
+ async receipt(to: string, query: ReceiptQuery): Promise<ReceiptResult> {
362
+ return await this.lastRequests.receipt(this.requireActiveSocket(), to, query);
364
363
  }
365
364
 
366
365
  respondLast(requestId: string, result: unknown): void {
367
- if (!isLastMessageResult(result)) {
366
+ if (!isNativeReadResult(result)) {
368
367
  throw new Error("Invalid last-message result");
369
368
  }
370
369
  writeMessage(this.requireActiveSocket(), { type: "last_response", requestId, result });
@@ -410,7 +409,9 @@ export class IntercomClient extends EventEmitter {
410
409
  const timeout = setTimeout(() => {
411
410
  if (this.pendingLists.has(requestId)) {
412
411
  this.pendingLists.delete(requestId);
413
- wrappedReject(new Error("List sessions timeout"));
412
+ wrappedReject(
413
+ Object.assign(new Error("List sessions timeout"), { code: "list_timeout" }),
414
+ );
414
415
  }
415
416
  }, this.listTimeoutMs);
416
417
  timeout.unref();
@@ -434,20 +435,33 @@ export class IntercomClient extends EventEmitter {
434
435
  });
435
436
  }
436
437
 
438
+ private requireSendSocket(options: SendOptions): net.Socket {
439
+ const socket = this.requireActiveSocket();
440
+ if (options.guard && !this.guardedDeliverySupported) {
441
+ throw new Error("This broker does not support guarded delivery; no message sent.");
442
+ }
443
+ return socket;
444
+ }
445
+
437
446
  send(to: string, options: SendOptions): Promise<SendResult> {
438
447
  let socket: net.Socket;
439
448
  try {
440
- socket = this.requireActiveSocket();
449
+ socket = this.requireSendSocket(options);
441
450
  } catch (error) {
442
451
  return Promise.reject(toError(error));
443
452
  }
444
453
 
445
- const messageId = options.messageId ?? randomUUID();
454
+ const messageId = options.messageId ?? (options.guard ? "" : randomUUID());
455
+ if (this.pendingSends.has(messageId)) {
456
+ return Promise.reject(new Error("Message ID already has a pending send"));
457
+ }
446
458
  const message: Message = {
447
459
  id: messageId,
448
460
  timestamp: Date.now(),
449
461
  replyTo: options.replyTo,
450
462
  expectsReply: options.expectsReply,
463
+ guard: options.guard,
464
+ replyHint: options.replyHint,
451
465
  ...(options.human ? { human: options.human } : {}),
452
466
  ...(options.topic ? { topic: options.topic } : {}),
453
467
  delivery: messageDelivery(options),
@@ -460,6 +474,9 @@ export class IntercomClient extends EventEmitter {
460
474
  },
461
475
  };
462
476
 
477
+ if ((options.guard || options.replyHint) && normalizeMessage(message) === null) {
478
+ return Promise.reject(new Error("Invalid message format"));
479
+ }
463
480
  return new Promise((resolve, reject) => {
464
481
  const wrappedResolve = (result: SendResult) => {
465
482
  clearTimeout(timeout);
@@ -493,6 +510,7 @@ export class IntercomClient extends EventEmitter {
493
510
  }
494
511
 
495
512
  updatePresence(updates: {
513
+ readonly applicationGuard?: string | null;
496
514
  readonly name?: string;
497
515
  readonly status?: string;
498
516
  readonly model?: string;