@ctliz/agent-intercom-claude 0.12.0-connect.3

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 (59) hide show
  1. package/.claude-plugin/plugin.json +26 -0
  2. package/.mcp.json +10 -0
  3. package/CHANGELOG.md +62 -0
  4. package/COPYRIGHT +6 -0
  5. package/LICENSE +661 -0
  6. package/LICENSE_TRANSITION.md +25 -0
  7. package/README.md +506 -0
  8. package/THIRD_PARTY_NOTICES.md +25 -0
  9. package/broker/access-credential.ts +76 -0
  10. package/broker/access-registry.ts +460 -0
  11. package/broker/audit.ts +60 -0
  12. package/broker/authorization.ts +125 -0
  13. package/broker/boss-contracts.ts +267 -0
  14. package/broker/broker.ts +2028 -0
  15. package/broker/client.ts +1048 -0
  16. package/broker/framing.ts +87 -0
  17. package/broker/ownership.ts +61 -0
  18. package/broker/paths.ts +161 -0
  19. package/broker/spawn.ts +463 -0
  20. package/broker/validation.ts +132 -0
  21. package/claude/cci.ts +512 -0
  22. package/claude/ccim.ts +31 -0
  23. package/claude/cli-runner.ts +220 -0
  24. package/claude/contact.ts +33 -0
  25. package/claude/inbox-monitor.ts +49 -0
  26. package/claude/inbox.ts +77 -0
  27. package/claude/mcp-protocol.ts +253 -0
  28. package/claude/native-bridge.ts +219 -0
  29. package/claude/native-protocol.ts +346 -0
  30. package/claude/permission-policy.ts +287 -0
  31. package/claude/runtime.ts +510 -0
  32. package/claude/server.ts +69 -0
  33. package/claude/team.ts +348 -0
  34. package/claude/transport.ts +97 -0
  35. package/claude/worker-config.ts +198 -0
  36. package/claude/worker-daemon.ts +418 -0
  37. package/commands/intercom-id.md +9 -0
  38. package/commands/intercom.md +8 -0
  39. package/config.ts +195 -0
  40. package/dist/broker.mjs +2530 -0
  41. package/dist/cci.mjs +3607 -0
  42. package/dist/ccim.mjs +3621 -0
  43. package/dist/claude-server.mjs +2730 -0
  44. package/dist/inbox-monitor.mjs +68 -0
  45. package/dist/worker-daemon.mjs +2797 -0
  46. package/durable-json.ts +25 -0
  47. package/licenses/MIT-pi-claude-link.txt +21 -0
  48. package/licenses/MIT-pi-intercom.txt +21 -0
  49. package/monitors/monitors.json +8 -0
  50. package/outbound-outbox.ts +116 -0
  51. package/package.json +93 -0
  52. package/protocol-v4/contract.ts +45 -0
  53. package/provider/protected-service.ts +162 -0
  54. package/provider/provider.mjs +34 -0
  55. package/scripts/build.mjs +71 -0
  56. package/scripts/verify-core-provenance.mjs +64 -0
  57. package/scripts/verify-packed-runtime.mjs +140 -0
  58. package/skills/claude-intercom/SKILL.md +90 -0
  59. package/types.ts +212 -0
@@ -0,0 +1,1048 @@
1
+ import { EventEmitter } from "events";
2
+ import net from "net";
3
+ import { randomUUID } from "crypto";
4
+ import { POLICY_SEMANTICS_HASH, POLICY_SEMANTICS_VERSION } from "@ctliz/agent-intercom-core";
5
+ import type { BossControlEnvelope } from "@ctliz/agent-intercom-core/boss";
6
+ import { writeMessage, createMessageReader } from "./framing.ts";
7
+ import { PersistentOutboundOutbox } from "../outbound-outbox.ts";
8
+ import { loadRemoteAccessCredential, writeRemoteSessionCredential, type LoadedRemoteAccessCredential } from "./access-credential.ts";
9
+ import {
10
+ getBrokerConnectTarget,
11
+ INTERCOM_PROTOCOL_NAME,
12
+ INTERCOM_PROTOCOL_VERSION,
13
+ type BrokerConnectTarget,
14
+ } from "./paths.ts";
15
+ import { intercomScopeIdFromEnvForRegistration, parseIntercomScopeIdForRegistration } from "../protocol-v4/contract.ts";
16
+ import type {
17
+ AskCancellationReason,
18
+ BossRegistrationRequest,
19
+ BossSessionMetadata,
20
+ DeliveryFailureCode,
21
+ SessionInfo,
22
+ Message,
23
+ Attachment,
24
+ SessionRegistration,
25
+ } from "../types.ts";
26
+ import { DELIVERY_FAILURE_CODES } from "../types.ts";
27
+ import {
28
+ assertBossRegistrationEcho,
29
+ assertCompatibleBossAdvertisement,
30
+ assertCompatibleOrdinaryAdvertisement,
31
+ parseBossRegistrationRequest,
32
+ parseBossSessionMetadata,
33
+ parseCorrelatedBossControl,
34
+ } from "./boss-contracts.ts";
35
+ import { hasExactDataKeys, isDenseArrayOf, isPlainDataRecord } from "./validation.ts";
36
+
37
+ export interface IntercomClientOptions {
38
+ /** Exact private registration scope captured for this client lifecycle. */
39
+ scopeId?: string;
40
+ /** Environment used only when scopeId is not explicitly supplied. */
41
+ env?: NodeJS.ProcessEnv;
42
+ }
43
+
44
+ export interface SendOptions {
45
+ text: string;
46
+ attachments?: Attachment[];
47
+ replyTo?: string;
48
+ expectsReply?: boolean;
49
+ messageId?: string;
50
+ }
51
+
52
+ export interface SendResult {
53
+ id: string;
54
+ accepted: boolean;
55
+ delivered: boolean;
56
+ deliveryId?: string;
57
+ code?: DeliveryFailureCode;
58
+ reason?: string;
59
+ }
60
+
61
+ interface InternalSendOptions extends SendOptions {
62
+ control?: BossControlEnvelope;
63
+ }
64
+
65
+ function toError(error: unknown): Error {
66
+ return error instanceof Error ? error : new Error(String(error));
67
+ }
68
+
69
+ function connectToBrokerTarget(target: BrokerConnectTarget): net.Socket {
70
+ return typeof target === "string"
71
+ ? net.connect(target)
72
+ : net.connect({ host: target.host, port: target.port });
73
+ }
74
+
75
+ function isAttachment(value: unknown): value is Attachment {
76
+ if (!hasExactDataKeys(value, ["type", "name", "content"], ["language"])) {
77
+ return false;
78
+ }
79
+
80
+ const attachment = value as Record<string, unknown>;
81
+
82
+ if (
83
+ attachment.type !== "file"
84
+ && attachment.type !== "snippet"
85
+ && attachment.type !== "context"
86
+ ) {
87
+ return false;
88
+ }
89
+
90
+ if (typeof attachment.name !== "string" || typeof attachment.content !== "string") {
91
+ return false;
92
+ }
93
+
94
+ return attachment.language === undefined || typeof attachment.language === "string";
95
+ }
96
+
97
+ function isMessage(value: unknown): value is Message {
98
+ if (!hasExactDataKeys(value, ["id", "timestamp", "content"], ["replyTo", "expectsReply", "control"])) {
99
+ return false;
100
+ }
101
+
102
+ const message = value as Record<string, unknown>;
103
+
104
+ if (typeof message.id !== "string" || typeof message.timestamp !== "number") {
105
+ return false;
106
+ }
107
+
108
+ if (message.replyTo !== undefined && typeof message.replyTo !== "string") {
109
+ return false;
110
+ }
111
+
112
+ if (message.expectsReply !== undefined && typeof message.expectsReply !== "boolean") {
113
+ return false;
114
+ }
115
+
116
+ if (message.control !== undefined) {
117
+ try {
118
+ parseCorrelatedBossControl(message.control, message.id);
119
+ } catch {
120
+ return false;
121
+ }
122
+ if (message.replyTo !== undefined || message.expectsReply !== undefined) return false;
123
+ }
124
+
125
+ if (!hasExactDataKeys(message.content, ["text"], ["attachments"])) {
126
+ return false;
127
+ }
128
+
129
+ const content = message.content as Record<string, unknown>;
130
+ if (typeof content.text !== "string") {
131
+ return false;
132
+ }
133
+
134
+ if (message.control !== undefined && (content.text !== "" || content.attachments !== undefined)) return false;
135
+
136
+ return content.attachments === undefined
137
+ || isDenseArrayOf(content.attachments, isAttachment);
138
+ }
139
+
140
+ function isSessionInfo(value: unknown): value is SessionInfo {
141
+ if (!hasExactDataKeys(
142
+ value,
143
+ ["id", "cwd", "model", "pid", "startedAt", "lastActivity"],
144
+ [
145
+ "name", "status", "peerUid", "trustedLocal", "origin", "remoteHostId",
146
+ "parentSessionId", "rootSessionId", "generation", "canDelegate", "depth",
147
+ "maxDepth", "maxChildren", "boss",
148
+ ],
149
+ )) {
150
+ return false;
151
+ }
152
+
153
+ const session = value as Record<string, unknown>;
154
+
155
+ if (
156
+ typeof session.id !== "string"
157
+ || typeof session.cwd !== "string"
158
+ || typeof session.model !== "string"
159
+ || typeof session.pid !== "number"
160
+ || typeof session.startedAt !== "number"
161
+ || typeof session.lastActivity !== "number"
162
+ ) {
163
+ return false;
164
+ }
165
+
166
+ if (session.name !== undefined && typeof session.name !== "string") {
167
+ return false;
168
+ }
169
+
170
+ if (session.status !== undefined && typeof session.status !== "string") {
171
+ return false;
172
+ }
173
+
174
+ if (session.peerUid !== undefined && typeof session.peerUid !== "number") {
175
+ return false;
176
+ }
177
+
178
+ if (session.trustedLocal !== undefined && typeof session.trustedLocal !== "boolean") return false;
179
+ if (session.origin !== undefined && session.origin !== "local" && session.origin !== "remote") return false;
180
+ if (session.remoteHostId !== undefined && typeof session.remoteHostId !== "string") return false;
181
+ if (session.parentSessionId !== undefined && typeof session.parentSessionId !== "string") return false;
182
+ if (session.rootSessionId !== undefined && typeof session.rootSessionId !== "string") return false;
183
+ if (session.generation !== undefined && (typeof session.generation !== "number" || !Number.isSafeInteger(session.generation))) return false;
184
+ if (session.canDelegate !== undefined && typeof session.canDelegate !== "boolean") return false;
185
+ for (const field of ["depth", "maxDepth", "maxChildren"] as const) {
186
+ if (session[field] !== undefined && (typeof session[field] !== "number" || !Number.isSafeInteger(session[field]))) return false;
187
+ }
188
+ if (session.boss !== undefined) {
189
+ try {
190
+ parseBossSessionMetadata(session.boss, session.id);
191
+ } catch {
192
+ return false;
193
+ }
194
+ }
195
+ return true;
196
+ }
197
+
198
+ function isRemoteAccessMetadata(value: unknown): value is import("../types.ts").RemoteAccessMetadata {
199
+ if (!hasExactDataKeys(
200
+ value,
201
+ ["origin", "remoteHostId", "parentSessionId", "rootSessionId", "generation", "canDelegate", "depth", "maxDepth", "maxChildren"],
202
+ ["sessionCredential"],
203
+ )) return false;
204
+ const access = value as Record<string, unknown>;
205
+ return access.origin === "remote"
206
+ && typeof access.remoteHostId === "string"
207
+ && typeof access.parentSessionId === "string"
208
+ && typeof access.rootSessionId === "string"
209
+ && typeof access.generation === "number"
210
+ && Number.isSafeInteger(access.generation)
211
+ && access.generation > 0
212
+ && typeof access.canDelegate === "boolean"
213
+ && typeof access.depth === "number"
214
+ && Number.isSafeInteger(access.depth)
215
+ && typeof access.maxDepth === "number"
216
+ && Number.isSafeInteger(access.maxDepth)
217
+ && typeof access.maxChildren === "number"
218
+ && Number.isSafeInteger(access.maxChildren)
219
+ && (access.sessionCredential === undefined || typeof access.sessionCredential === "string");
220
+ }
221
+
222
+ function isDeliveryFailureCode(value: unknown): value is DeliveryFailureCode {
223
+ return typeof value === "string" && (DELIVERY_FAILURE_CODES as readonly string[]).includes(value);
224
+ }
225
+
226
+ export class IntercomClient extends EventEmitter {
227
+ private socket: net.Socket | null = null;
228
+ private readonly scopeId: string | undefined;
229
+ private _sessionId: string | null = null;
230
+ private pendingSends = new Map<string, {
231
+ accepted: boolean;
232
+ deliveryId?: string;
233
+ resolve: (r: SendResult) => void;
234
+ reject: (e: Error) => void;
235
+ }>();
236
+ private lateDeliveryAcceptances = new Map<string, string>();
237
+ private pendingLists = new Map<string, { resolve: (sessions: SessionInfo[]) => void; reject: (e: Error) => void }>();
238
+ private pendingAskControls = new Map<string, { resolve: (applied: boolean) => void; timeout: NodeJS.Timeout }>();
239
+ private outbox: PersistentOutboundOutbox | null = null;
240
+ private remoteAccessCredential: LoadedRemoteAccessCredential | undefined;
241
+ private requestedBossRegistration: BossRegistrationRequest | undefined;
242
+ private bossSessionMetadata: BossSessionMetadata | undefined;
243
+ private disconnecting = false;
244
+ private disconnectError: Error | null = null;
245
+
246
+ constructor(private readonly options: IntercomClientOptions = {}) {
247
+ super();
248
+ this.scopeId = options.scopeId === undefined
249
+ ? intercomScopeIdFromEnvForRegistration(options.env ?? process.env)
250
+ : parseIntercomScopeIdForRegistration(options.scopeId);
251
+ }
252
+
253
+ private failPending(error: Error): void {
254
+ for (const pending of this.pendingSends.values()) {
255
+ pending.reject(error);
256
+ }
257
+ this.pendingSends.clear();
258
+ for (const pending of this.pendingLists.values()) {
259
+ pending.reject(error);
260
+ }
261
+ this.pendingLists.clear();
262
+ for (const pending of this.pendingAskControls.values()) {
263
+ clearTimeout(pending.timeout);
264
+ pending.resolve(false);
265
+ }
266
+ this.pendingAskControls.clear();
267
+ this.lateDeliveryAcceptances.clear();
268
+ }
269
+
270
+ private hasQueuedOutboundMessage(messageId: string): boolean {
271
+ return this.outbox?.list().some((entry) => entry.message.id === messageId) ?? false;
272
+ }
273
+
274
+ get sessionId(): string | null {
275
+ return this._sessionId;
276
+ }
277
+
278
+ get outboxSize(): number {
279
+ return this.outbox?.list().length ?? 0;
280
+ }
281
+
282
+ isConnected(): boolean {
283
+ const socket = this.socket;
284
+ return Boolean(socket && this._sessionId && !this.disconnecting && !socket.destroyed && !socket.writableEnded && socket.writable);
285
+ }
286
+
287
+ private requireActiveSocket(): net.Socket {
288
+ if (this.disconnecting) {
289
+ throw new Error("Client disconnecting");
290
+ }
291
+
292
+ const socket = this.socket;
293
+ if (!socket || !this._sessionId) {
294
+ throw new Error("Not connected");
295
+ }
296
+
297
+ if (socket.destroyed || socket.writableEnded || !socket.writable) {
298
+ throw new Error("Client disconnected");
299
+ }
300
+
301
+ return socket;
302
+ }
303
+
304
+ connect(session: SessionRegistration, sessionId?: string): Promise<void> {
305
+ if (this.socket) {
306
+ return Promise.reject(new Error("Already connected"));
307
+ }
308
+
309
+ return new Promise((resolve, reject) => {
310
+ let socket: net.Socket;
311
+ let target: BrokerConnectTarget;
312
+ try {
313
+ this.requestedBossRegistration = session.boss === undefined
314
+ ? undefined
315
+ : parseBossRegistrationRequest(session.boss);
316
+ target = getBrokerConnectTarget();
317
+ this.remoteAccessCredential = loadRemoteAccessCredential();
318
+ socket = connectToBrokerTarget(target);
319
+ } catch (error) {
320
+ this.requestedBossRegistration = undefined;
321
+ reject(toError(error));
322
+ return;
323
+ }
324
+ this.socket = socket;
325
+ this.disconnectError = null;
326
+ let settled = false;
327
+ const timeout = setTimeout(() => {
328
+ if (!this._sessionId) {
329
+ cleanupConnectionAttempt();
330
+ cleanupSocketListeners();
331
+ if (this.socket === socket) {
332
+ this.socket = null;
333
+ }
334
+ this.requestedBossRegistration = undefined;
335
+ socket.destroy();
336
+ reject(new Error("Connection timeout"));
337
+ }
338
+ }, 10000);
339
+
340
+ let connectionEstablished = false;
341
+
342
+ const onRegistered = () => {
343
+ settled = true;
344
+ connectionEstablished = true;
345
+ cleanupConnectionAttempt();
346
+ resolve();
347
+ };
348
+
349
+ const onError = (err: Error) => {
350
+ settled = true;
351
+ cleanupConnectionAttempt();
352
+ cleanupSocketListeners();
353
+ if (this.socket === socket) {
354
+ this.socket = null;
355
+ }
356
+ this.requestedBossRegistration = undefined;
357
+ socket.destroy();
358
+ reject(err);
359
+ };
360
+
361
+ const onClose = () => {
362
+ const wasConnecting = !settled && !this._sessionId;
363
+ const wasDisconnecting = this.disconnecting;
364
+ const disconnectError = this.disconnectError ?? new Error("Client disconnected");
365
+ this.disconnecting = false;
366
+ cleanupConnectionAttempt();
367
+ cleanupSocketListeners();
368
+ this.failPending(disconnectError);
369
+ if (this.socket === socket) {
370
+ this.socket = null;
371
+ }
372
+ this._sessionId = null;
373
+ this.requestedBossRegistration = undefined;
374
+ this.bossSessionMetadata = undefined;
375
+ this.disconnectError = null;
376
+ if (connectionEstablished && !wasDisconnecting) {
377
+ this.emit("disconnected", disconnectError);
378
+ }
379
+ if (wasConnecting) {
380
+ reject(new Error("Connection closed before registration"));
381
+ }
382
+ };
383
+
384
+ const onSocketError = (err: Error) => {
385
+ if (connectionEstablished) {
386
+ this.disconnectError = err;
387
+ this.emit("error", err);
388
+ }
389
+ };
390
+
391
+ const onReaderError = (error: Error) => {
392
+ const protocolError = new Error(`Intercom protocol error: ${error.message}`, { cause: error });
393
+ if (!connectionEstablished) {
394
+ onError(protocolError);
395
+ return;
396
+ }
397
+ this.disconnectError = protocolError;
398
+ this.emit("error", protocolError);
399
+ socket.destroy();
400
+ };
401
+
402
+ const reader = createMessageReader((msg) => {
403
+ this.handleBrokerMessage(msg);
404
+ }, onReaderError);
405
+
406
+ const cleanupConnectionAttempt = () => {
407
+ this.off("_registered", onRegistered);
408
+ socket.off("error", onError);
409
+ clearTimeout(timeout);
410
+ };
411
+
412
+ const cleanupSocketListeners = () => {
413
+ socket.off("data", reader);
414
+ socket.off("error", onSocketError);
415
+ socket.off("close", onClose);
416
+ };
417
+
418
+ socket.on("data", reader);
419
+ socket.on("error", onError);
420
+ socket.on("close", onClose);
421
+
422
+ socket.on("error", onSocketError);
423
+ this.once("_registered", onRegistered);
424
+
425
+ try {
426
+ writeMessage(socket, {
427
+ type: "register",
428
+ protocol: INTERCOM_PROTOCOL_NAME,
429
+ version: INTERCOM_PROTOCOL_VERSION,
430
+ session,
431
+ ...(!this.remoteAccessCredential && sessionId ? { sessionId } : {}),
432
+ ...(this.remoteAccessCredential ? { access: this.remoteAccessCredential.access } : {}),
433
+ ...(this.scopeId ? { scopeId: this.scopeId } : {}),
434
+ ...(typeof target === "string" ? {} : { stateId: target.stateId }),
435
+ });
436
+ } catch (error) {
437
+ cleanupConnectionAttempt();
438
+ cleanupSocketListeners();
439
+ if (this.socket === socket) {
440
+ this.socket = null;
441
+ }
442
+ this.requestedBossRegistration = undefined;
443
+ socket.destroy();
444
+ reject(toError(error));
445
+ }
446
+ });
447
+ }
448
+
449
+ private handleBrokerMessage(msg: unknown): void {
450
+ if (!isPlainDataRecord(msg) || typeof msg.type !== "string") {
451
+ throw new Error("Invalid broker message");
452
+ }
453
+
454
+ const brokerMessage = msg as { type: string } & Record<string, unknown>;
455
+
456
+ if (this._sessionId === null && brokerMessage.type !== "registered" && brokerMessage.type !== "error") {
457
+ throw new Error(`Received ${brokerMessage.type} before registered`);
458
+ }
459
+
460
+ switch (brokerMessage.type) {
461
+ case "registered": {
462
+ const baseKeys = ["type", "sessionId", "protocol", "version"];
463
+ if (
464
+ this.requestedBossRegistration !== undefined
465
+ && (brokerMessage.capabilities === undefined || brokerMessage.boss === undefined)
466
+ ) {
467
+ throw new Error("Boss registration was downgraded to an ordinary session");
468
+ }
469
+ const exactKeys = this.requestedBossRegistration !== undefined
470
+ ? [...baseKeys, "capabilities", "boss"]
471
+ : this.remoteAccessCredential
472
+ ? [...baseKeys, "remoteAccess", "access"]
473
+ : baseKeys;
474
+ const optionalKeys = this.requestedBossRegistration === undefined ? ["capabilities"] : [];
475
+ if (
476
+ !hasExactDataKeys(brokerMessage, exactKeys, optionalKeys)
477
+ || typeof brokerMessage.sessionId !== "string"
478
+ || brokerMessage.protocol !== INTERCOM_PROTOCOL_NAME
479
+ || brokerMessage.version !== INTERCOM_PROTOCOL_VERSION
480
+ ) {
481
+ throw new Error("Invalid registered message");
482
+ }
483
+
484
+ if (this._sessionId !== null) {
485
+ throw new Error("Received duplicate registered message");
486
+ }
487
+
488
+ if (this.requestedBossRegistration !== undefined) {
489
+ assertCompatibleBossAdvertisement(brokerMessage.capabilities);
490
+ this.bossSessionMetadata = assertBossRegistrationEcho(
491
+ this.requestedBossRegistration,
492
+ brokerMessage.boss,
493
+ brokerMessage.sessionId,
494
+ );
495
+ this.requestedBossRegistration = undefined;
496
+ } else {
497
+ if (brokerMessage.boss !== undefined) {
498
+ throw new Error("Ordinary registration received unsolicited Boss authority metadata");
499
+ }
500
+ if (brokerMessage.capabilities !== undefined) {
501
+ assertCompatibleOrdinaryAdvertisement(brokerMessage.capabilities);
502
+ }
503
+ this.bossSessionMetadata = undefined;
504
+ }
505
+
506
+ if (this.remoteAccessCredential) {
507
+ const contract = brokerMessage.remoteAccess;
508
+ const contractFields = hasExactDataKeys(
509
+ contract,
510
+ ["feature", "policySemanticsVersion", "policySemanticsHash"],
511
+ ) ? contract : undefined;
512
+ if (
513
+ !contractFields
514
+ || contractFields.feature !== "remote-access-v1"
515
+ || contractFields.policySemanticsVersion !== POLICY_SEMANTICS_VERSION
516
+ || contractFields.policySemanticsHash !== POLICY_SEMANTICS_HASH
517
+ ) {
518
+ throw new Error("Remote Intercom policy contract is absent or incompatible");
519
+ }
520
+ if (!isRemoteAccessMetadata(brokerMessage.access)) {
521
+ throw new Error("Remote Intercom registration omitted broker-owned provenance");
522
+ }
523
+ if (this.remoteAccessCredential.enrollment) {
524
+ writeRemoteSessionCredential(this.remoteAccessCredential.path, brokerMessage.sessionId, brokerMessage.access);
525
+ } else {
526
+ const reconnect = this.remoteAccessCredential.access;
527
+ if (
528
+ !isPlainDataRecord(reconnect)
529
+ || reconnect.sessionId !== brokerMessage.sessionId
530
+ || reconnect.generation !== brokerMessage.access.generation
531
+ ) {
532
+ throw new Error("Remote Intercom reconnect identity or generation changed unexpectedly");
533
+ }
534
+ }
535
+ }
536
+
537
+ this._sessionId = brokerMessage.sessionId;
538
+ this.outbox = new PersistentOutboundOutbox(brokerMessage.sessionId);
539
+ this.lateDeliveryAcceptances.clear();
540
+ this.replayOutbox();
541
+ this.emit("_registered", { type: "registered", sessionId: brokerMessage.sessionId });
542
+ break;
543
+ }
544
+
545
+ case "sessions": {
546
+ const { requestId, sessions } = brokerMessage;
547
+ if (
548
+ !hasExactDataKeys(brokerMessage, ["type", "requestId", "sessions"])
549
+ || typeof requestId !== "string"
550
+ || !isDenseArrayOf(sessions, isSessionInfo)
551
+ ) {
552
+ throw new Error("Invalid sessions message");
553
+ }
554
+
555
+ const pending = this.pendingLists.get(requestId);
556
+ if (!pending) {
557
+ // Late list responses can still arrive after the caller has already timed out.
558
+ return;
559
+ }
560
+
561
+ this.pendingLists.delete(requestId);
562
+ pending.resolve(sessions);
563
+ break;
564
+ }
565
+
566
+ case "message": {
567
+ const { deliveryId, from, message } = brokerMessage;
568
+ if (
569
+ !hasExactDataKeys(brokerMessage, ["type", "deliveryId", "from", "message"])
570
+ || typeof deliveryId !== "string"
571
+ || !isSessionInfo(from)
572
+ || !isMessage(message)
573
+ ) {
574
+ throw new Error("Invalid message event");
575
+ }
576
+
577
+ if (message.control !== undefined) {
578
+ if (this.bossSessionMetadata === undefined) {
579
+ throw new Error("Ordinary session received unavailable Boss control traffic");
580
+ }
581
+ this.emit("control", from, message.control, deliveryId);
582
+ } else {
583
+ this.emit("message", from, message, deliveryId);
584
+ }
585
+ break;
586
+ }
587
+
588
+ case "delivery_accepted": {
589
+ const { deliveryId, messageId } = brokerMessage;
590
+ if (
591
+ !hasExactDataKeys(brokerMessage, ["type", "messageId", "deliveryId"])
592
+ || typeof deliveryId !== "string"
593
+ || typeof messageId !== "string"
594
+ ) {
595
+ throw new Error("Invalid delivery_accepted message");
596
+ }
597
+
598
+ const pending = this.pendingSends.get(messageId);
599
+ if (pending) {
600
+ if (pending.accepted) {
601
+ throw new Error("Duplicate delivery_accepted message");
602
+ }
603
+ pending.accepted = true;
604
+ pending.deliveryId = deliveryId;
605
+ this.emit("delivery_accepted", messageId, deliveryId);
606
+ break;
607
+ }
608
+ if (!this.hasQueuedOutboundMessage(messageId)) {
609
+ throw new Error("Unexpected delivery_accepted message without a pending send or queued outbox message");
610
+ }
611
+ if (this.lateDeliveryAcceptances.has(messageId)) {
612
+ throw new Error("Duplicate delivery_accepted message");
613
+ }
614
+ this.lateDeliveryAcceptances.set(messageId, deliveryId);
615
+ this.emit("delivery_accepted", messageId, deliveryId);
616
+ break;
617
+ }
618
+
619
+ case "delivered": {
620
+ const { deliveryId, messageId } = brokerMessage;
621
+ if (
622
+ !hasExactDataKeys(brokerMessage, ["type", "messageId", "deliveryId"])
623
+ || typeof deliveryId !== "string"
624
+ || typeof messageId !== "string"
625
+ ) {
626
+ throw new Error("Invalid delivered message");
627
+ }
628
+
629
+ const pending = this.pendingSends.get(messageId);
630
+ if (pending) {
631
+ if (!pending.accepted) {
632
+ throw new Error("Delivered message arrived before delivery acceptance");
633
+ }
634
+ if (pending.deliveryId !== deliveryId) {
635
+ throw new Error("Delivered message used a mismatched delivery ID");
636
+ }
637
+
638
+ this.outbox?.remove(messageId);
639
+ this.pendingSends.delete(messageId);
640
+ this.lateDeliveryAcceptances.delete(messageId);
641
+ pending.resolve({ id: messageId, accepted: true, delivered: true, deliveryId });
642
+ break;
643
+ }
644
+ if (!this.hasQueuedOutboundMessage(messageId)) {
645
+ throw new Error("Unexpected delivered message without a pending send or queued outbox message");
646
+ }
647
+ const acceptedDeliveryId = this.lateDeliveryAcceptances.get(messageId);
648
+ if (acceptedDeliveryId === undefined) {
649
+ throw new Error("Delivered message arrived before delivery acceptance");
650
+ }
651
+ if (acceptedDeliveryId !== deliveryId) {
652
+ throw new Error("Delivered message used a mismatched delivery ID");
653
+ }
654
+
655
+ this.outbox?.remove(messageId);
656
+ this.lateDeliveryAcceptances.delete(messageId);
657
+ this.emit("outbox_delivered", messageId, deliveryId);
658
+ break;
659
+ }
660
+
661
+ case "delivery_failed": {
662
+ const { accepted, code, messageId, reason } = brokerMessage;
663
+ if (
664
+ !hasExactDataKeys(brokerMessage, ["type", "messageId", "accepted", "code", "reason"])
665
+ || typeof accepted !== "boolean"
666
+ || !isDeliveryFailureCode(code)
667
+ || typeof messageId !== "string"
668
+ || typeof reason !== "string"
669
+ ) {
670
+ throw new Error("Invalid delivery_failed message");
671
+ }
672
+
673
+ const pending = this.pendingSends.get(messageId);
674
+ if (pending) {
675
+ if (accepted !== pending.accepted) {
676
+ throw new Error("Delivery failure contradicted the accepted delivery state");
677
+ }
678
+
679
+ this.outbox?.remove(messageId);
680
+ this.pendingSends.delete(messageId);
681
+ this.lateDeliveryAcceptances.delete(messageId);
682
+ pending.resolve({
683
+ id: messageId,
684
+ accepted,
685
+ delivered: false,
686
+ code,
687
+ reason,
688
+ ...(pending.deliveryId !== undefined ? { deliveryId: pending.deliveryId } : {}),
689
+ });
690
+ break;
691
+ }
692
+ if (!this.hasQueuedOutboundMessage(messageId)) {
693
+ throw new Error("Unexpected delivery_failed message without a pending send or queued outbox message");
694
+ }
695
+ if (accepted !== this.lateDeliveryAcceptances.has(messageId)) {
696
+ throw new Error("Delivery failure contradicted the accepted delivery state");
697
+ }
698
+
699
+ this.outbox?.remove(messageId);
700
+ this.lateDeliveryAcceptances.delete(messageId);
701
+ this.emit("outbox_failed", messageId, code, reason);
702
+ break;
703
+ }
704
+
705
+ case "ask_deferred": {
706
+ const { fromSessionId, messageId } = brokerMessage;
707
+ if (typeof fromSessionId !== "string" || typeof messageId !== "string") {
708
+ throw new Error("Invalid ask_deferred message");
709
+ }
710
+ this.emit("ask_deferred", messageId, fromSessionId);
711
+ break;
712
+ }
713
+
714
+ case "ask_cancelled": {
715
+ const { fromSessionId, messageId, reason } = brokerMessage;
716
+ if (typeof fromSessionId !== "string" || typeof messageId !== "string" || typeof reason !== "string") {
717
+ throw new Error("Invalid ask_cancelled message");
718
+ }
719
+ this.emit("ask_cancelled", messageId, fromSessionId, reason as AskCancellationReason);
720
+ break;
721
+ }
722
+
723
+ case "ask_control_result": {
724
+ const { action, applied, messageId, requestId } = brokerMessage;
725
+ if (
726
+ (action !== "defer" && action !== "cancel")
727
+ || typeof applied !== "boolean"
728
+ || typeof messageId !== "string"
729
+ || typeof requestId !== "string"
730
+ ) {
731
+ throw new Error("Invalid ask_control_result message");
732
+ }
733
+ const pending = this.pendingAskControls.get(requestId);
734
+ if (!pending) return;
735
+ clearTimeout(pending.timeout);
736
+ this.pendingAskControls.delete(requestId);
737
+ pending.resolve(applied);
738
+ break;
739
+ }
740
+
741
+ case "session_joined": {
742
+ if (!isSessionInfo(brokerMessage.session)) {
743
+ throw new Error("Invalid session_joined message");
744
+ }
745
+
746
+ this.emit("session_joined", brokerMessage.session);
747
+ break;
748
+ }
749
+
750
+ case "session_left": {
751
+ if (typeof brokerMessage.sessionId !== "string") {
752
+ throw new Error("Invalid session_left message");
753
+ }
754
+
755
+ this.emit("session_left", brokerMessage.sessionId);
756
+ break;
757
+ }
758
+
759
+ case "presence_update": {
760
+ if (!isSessionInfo(brokerMessage.session)) {
761
+ throw new Error("Invalid presence_update message");
762
+ }
763
+
764
+ this.emit("presence_update", brokerMessage.session);
765
+ break;
766
+ }
767
+
768
+ case "error": {
769
+ if (typeof brokerMessage.code !== "string" || typeof brokerMessage.error !== "string") {
770
+ throw new Error("Invalid error message");
771
+ }
772
+
773
+ if (this._sessionId === null) {
774
+ const error = new Error(brokerMessage.error) as Error & { code?: string };
775
+ error.code = brokerMessage.code;
776
+ throw error;
777
+ }
778
+ const error = new Error(brokerMessage.error) as Error & { code?: string };
779
+ error.code = brokerMessage.code;
780
+ this.emit("error", error);
781
+ break;
782
+ }
783
+
784
+ default:
785
+ throw new Error(`Unknown broker message type: ${brokerMessage.type}`);
786
+ }
787
+ }
788
+
789
+ async disconnect(preserveAsks = false): Promise<void> {
790
+ const socket = this.socket;
791
+ if (!socket) {
792
+ return;
793
+ }
794
+
795
+ this.disconnecting = true;
796
+ this.disconnectError = null;
797
+ this.failPending(new Error("Client disconnected"));
798
+ if (!preserveAsks) this.outbox?.clear();
799
+
800
+ await new Promise<void>((resolve) => {
801
+ let settled = false;
802
+ const finish = () => {
803
+ if (settled) {
804
+ return;
805
+ }
806
+ settled = true;
807
+ clearTimeout(timeout);
808
+ socket.off("close", onClose);
809
+ socket.off("error", onError);
810
+ resolve();
811
+ };
812
+ const onClose = () => finish();
813
+ const onError = () => {
814
+ socket.destroy();
815
+ };
816
+ const timeout = setTimeout(() => {
817
+ socket.destroy();
818
+ }, 2000);
819
+
820
+ socket.once("close", onClose);
821
+ socket.once("error", onError);
822
+
823
+ try {
824
+ writeMessage(socket, { type: "unregister", ...(preserveAsks ? { preserveAsks: true } : {}) });
825
+ socket.end();
826
+ } catch {
827
+ // Disconnect should still finish even if the unregister write fails.
828
+ socket.destroy();
829
+ }
830
+ });
831
+ }
832
+
833
+ listSessions(): Promise<SessionInfo[]> {
834
+ let socket: net.Socket;
835
+ try {
836
+ socket = this.requireActiveSocket();
837
+ } catch (error) {
838
+ return Promise.reject(toError(error));
839
+ }
840
+
841
+ return new Promise((resolve, reject) => {
842
+ const requestId = randomUUID();
843
+ const wrappedResolve = (sessions: SessionInfo[]) => {
844
+ clearTimeout(timeout);
845
+ resolve(sessions);
846
+ };
847
+ const wrappedReject = (error: Error) => {
848
+ clearTimeout(timeout);
849
+ reject(error);
850
+ };
851
+ const timeout = setTimeout(() => {
852
+ if (this.pendingLists.has(requestId)) {
853
+ this.pendingLists.delete(requestId);
854
+ wrappedReject(new Error("List sessions timeout"));
855
+ }
856
+ }, 5000);
857
+ this.pendingLists.set(requestId, { resolve: wrappedResolve, reject: wrappedReject });
858
+ try {
859
+ writeMessage(socket, { type: "list", requestId });
860
+ } catch (error) {
861
+ clearTimeout(timeout);
862
+ this.pendingLists.delete(requestId);
863
+ reject(toError(error));
864
+ }
865
+ });
866
+ }
867
+
868
+ send(to: string, options: SendOptions): Promise<SendResult> {
869
+ return this.sendInternal(to, options);
870
+ }
871
+
872
+ sendControl(to: string, controlValue: BossControlEnvelope): Promise<SendResult> {
873
+ const control = parseCorrelatedBossControl(controlValue);
874
+ return this.sendInternal(to, {
875
+ text: "",
876
+ messageId: control.messageId,
877
+ control,
878
+ });
879
+ }
880
+
881
+ private sendInternal(to: string, options: InternalSendOptions): Promise<SendResult> {
882
+ let socket: net.Socket;
883
+ try {
884
+ socket = this.requireActiveSocket();
885
+ } catch (error) {
886
+ return Promise.reject(toError(error));
887
+ }
888
+
889
+ const messageId = options.messageId ?? randomUUID();
890
+ if (this.pendingSends.has(messageId)) {
891
+ return Promise.resolve({
892
+ id: messageId,
893
+ accepted: false,
894
+ delivered: false,
895
+ code: "DUPLICATE_MESSAGE_ID",
896
+ reason: `Message ID ${messageId} is already pending`,
897
+ });
898
+ }
899
+ const message: Message = {
900
+ id: messageId,
901
+ timestamp: Date.now(),
902
+ replyTo: options.replyTo,
903
+ expectsReply: options.expectsReply,
904
+ ...(options.control === undefined ? {} : { control: options.control }),
905
+ content: {
906
+ text: options.text,
907
+ ...(options.attachments === undefined ? {} : { attachments: options.attachments }),
908
+ },
909
+ };
910
+
911
+ try {
912
+ this.outbox?.enqueue(to, message);
913
+ } catch (error) {
914
+ return Promise.reject(toError(error));
915
+ }
916
+
917
+ return new Promise((resolve, reject) => {
918
+ const wrappedResolve = (result: SendResult) => {
919
+ clearTimeout(timeout);
920
+ resolve(result);
921
+ };
922
+ const wrappedReject = (error: Error) => {
923
+ clearTimeout(timeout);
924
+ reject(error);
925
+ };
926
+ const timeout = setTimeout(() => {
927
+ const pending = this.pendingSends.get(messageId);
928
+ if (pending) {
929
+ if (
930
+ pending.accepted
931
+ && pending.deliveryId !== undefined
932
+ && this.hasQueuedOutboundMessage(messageId)
933
+ ) {
934
+ this.lateDeliveryAcceptances.set(messageId, pending.deliveryId);
935
+ }
936
+ this.pendingSends.delete(messageId);
937
+ wrappedReject(new Error("Send timeout"));
938
+ }
939
+ }, 10000);
940
+ this.pendingSends.set(messageId, {
941
+ accepted: false,
942
+ resolve: wrappedResolve,
943
+ reject: wrappedReject,
944
+ });
945
+
946
+ try {
947
+ writeMessage(socket, { type: "send", to, message });
948
+ } catch (error) {
949
+ clearTimeout(timeout);
950
+ this.pendingSends.delete(messageId);
951
+ reject(toError(error));
952
+ }
953
+ });
954
+ }
955
+
956
+ acknowledgeMessage(deliveryId: string): boolean {
957
+ return this.writeControlMessage({ type: "message_received", deliveryId });
958
+ }
959
+
960
+ rejectMessage(deliveryId: string, reason: string): boolean {
961
+ return this.writeControlMessage({ type: "message_rejected", deliveryId, code: "CONFLICTING_MESSAGE_ID", reason });
962
+ }
963
+
964
+ deferAsk(messageId: string): Promise<boolean> {
965
+ return this.sendAskControl("defer", messageId);
966
+ }
967
+
968
+ cancelAsk(messageId: string): Promise<boolean> {
969
+ return this.sendAskControl("cancel", messageId);
970
+ }
971
+
972
+ private sendAskControl(action: "defer" | "cancel", messageId: string): Promise<boolean> {
973
+ const requestId = randomUUID();
974
+ return new Promise((resolve) => {
975
+ const timeout = setTimeout(() => {
976
+ this.pendingAskControls.delete(requestId);
977
+ resolve(false);
978
+ }, 2000);
979
+ timeout.unref?.();
980
+ this.pendingAskControls.set(requestId, { resolve, timeout });
981
+ if (!this.writeControlMessage({ type: action === "defer" ? "defer_ask" : "cancel_ask", requestId, messageId })) {
982
+ clearTimeout(timeout);
983
+ this.pendingAskControls.delete(requestId);
984
+ resolve(false);
985
+ }
986
+ });
987
+ }
988
+
989
+ private writeControlMessage(message: Record<string, unknown>): boolean {
990
+ if (this.disconnecting) {
991
+ return false;
992
+ }
993
+
994
+ const socket = this.socket;
995
+ if (!socket || !this._sessionId || socket.destroyed || socket.writableEnded || !socket.writable) {
996
+ return false;
997
+ }
998
+
999
+ try {
1000
+ writeMessage(socket, message);
1001
+ return true;
1002
+ } catch {
1003
+ // Control messages are best-effort; local cleanup must still proceed.
1004
+ return false;
1005
+ }
1006
+ }
1007
+
1008
+ private replayOutbox(): void {
1009
+ const socket = this.socket;
1010
+ if (!socket || !this._sessionId || socket.destroyed || socket.writableEnded || !socket.writable) return;
1011
+ for (const entry of this.outbox?.list() ?? []) {
1012
+ if (this.pendingSends.has(entry.message.id)) continue;
1013
+ this.lateDeliveryAcceptances.delete(entry.message.id);
1014
+ this.pendingSends.set(entry.message.id, {
1015
+ accepted: false,
1016
+ resolve: (result) => {
1017
+ if (result.delivered && result.deliveryId) {
1018
+ this.emit("outbox_delivered", result.id, result.deliveryId);
1019
+ } else {
1020
+ this.emit("outbox_failed", result.id, result.code, result.reason);
1021
+ }
1022
+ },
1023
+ reject: () => {
1024
+ // The durable entry remains queued and a later connection replays it.
1025
+ },
1026
+ });
1027
+ try {
1028
+ writeMessage(socket, { type: "send", to: entry.to, message: entry.message });
1029
+ } catch {
1030
+ this.pendingSends.delete(entry.message.id);
1031
+ return;
1032
+ }
1033
+ }
1034
+ }
1035
+
1036
+ updatePresence(updates: { name?: string; status?: string; model?: string }): void {
1037
+ if (this.disconnecting) {
1038
+ return;
1039
+ }
1040
+
1041
+ const socket = this.socket;
1042
+ if (!socket || !this._sessionId || socket.destroyed || socket.writableEnded || !socket.writable) {
1043
+ return;
1044
+ }
1045
+
1046
+ writeMessage(socket, { type: "presence", ...updates });
1047
+ }
1048
+ }