@fitchmultz/pi-subagents 0.52.20 → 0.52.21

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 (44) hide show
  1. package/CHANGELOG.md +30 -2
  2. package/dist/agent/contracts.js +12 -3
  3. package/dist/agent/index.js +9 -9
  4. package/dist/agent/legacy.js +5 -5
  5. package/dist/agent/notifications.js +21 -14
  6. package/dist/agent/parent-check.js +38 -0
  7. package/dist/agent/process.js +45 -52
  8. package/dist/agent/runner.js +59 -64
  9. package/dist/agent/storage.js +16 -4
  10. package/dist/agent/view.js +1 -3
  11. package/dist/build-info.js +1 -1
  12. package/dist/pi-intercom/broker/broker.js +4 -26
  13. package/dist/pi-intercom/broker/client-park.d.ts +1 -1
  14. package/dist/pi-intercom/broker/client-park.js +22 -6
  15. package/dist/pi-intercom/broker/client.d.ts +3 -2
  16. package/dist/pi-intercom/broker/client.js +88 -62
  17. package/dist/pi-intercom/broker/connection.d.ts +3 -5
  18. package/dist/pi-intercom/broker/connection.js +48 -33
  19. package/dist/pi-intercom/broker/framing.d.ts +1 -1
  20. package/dist/pi-intercom/broker/framing.js +19 -12
  21. package/dist/pi-intercom/broker/replace-deliveries.js +38 -0
  22. package/dist/pi-intercom/connection.js +5 -8
  23. package/dist/pi-intercom/inbound-journal.js +5 -2
  24. package/dist/shared/journal-frames.js +19 -24
  25. package/docs/development.md +6 -0
  26. package/package.json +1 -1
  27. package/src/agent/contracts.ts +12 -3
  28. package/src/agent/index.ts +10 -10
  29. package/src/agent/legacy.ts +5 -5
  30. package/src/agent/notifications.ts +35 -15
  31. package/src/agent/parent-check.ts +40 -0
  32. package/src/agent/process.ts +53 -55
  33. package/src/agent/runner.ts +65 -65
  34. package/src/agent/storage.ts +18 -5
  35. package/src/agent/view.ts +1 -4
  36. package/src/pi-intercom/broker/broker.ts +5 -37
  37. package/src/pi-intercom/broker/client-park.ts +27 -6
  38. package/src/pi-intercom/broker/client.ts +91 -61
  39. package/src/pi-intercom/broker/connection.ts +58 -39
  40. package/src/pi-intercom/broker/framing.ts +24 -12
  41. package/src/pi-intercom/broker/replace-deliveries.ts +56 -0
  42. package/src/pi-intercom/connection.ts +9 -9
  43. package/src/pi-intercom/inbound-journal.ts +5 -2
  44. package/src/shared/journal-frames.ts +22 -23
@@ -10,8 +10,9 @@ import {
10
10
  type Result,
11
11
  type AccountingState,
12
12
  } from "./contracts.ts";
13
- import { launchAt, missing, read, runDirectory, write } from "./storage.ts";
14
- import { ownIdentity, ParentCheck, shells, signalProcess } from "./process.ts";
13
+ import { commandIdentity, launchAt, read, runDirectory, write } from "./storage.ts";
14
+ import { ownIdentity, shells, signalProcess } from "./process.ts";
15
+ import { ParentCheck } from "./parent-check.ts";
15
16
  import { errorText, loadSDK } from "./host.ts";
16
17
  import { Parking, type ParkOutcome, type Runtime } from "./parking.ts";
17
18
  import { accountingFields, UsageIndex } from "./usage.ts";
@@ -20,17 +21,6 @@ import { childRuntime } from "./child-runtime.ts";
20
21
  import { ActorObservation, type ObservationStats } from "./observation.ts";
21
22
  import { DirectoryWatch, Wake } from "./wake.ts";
22
23
 
23
- function commandIdentity(file: string): string | undefined {
24
- try {
25
- const stat = fs.statSync(file, { bigint: true });
26
- return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
27
- } catch (error) {
28
- if (missing(error)) {
29
- return;
30
- }
31
- throw error;
32
- }
33
- }
34
24
  function completionStatus(
35
25
  stopping: boolean,
36
26
  message: AssistantMessage | undefined,
@@ -53,7 +43,11 @@ const failureReason = (
53
43
  message?.errorMessage ??
54
44
  (message ? undefined : "Native run produced no terminal assistant message.");
55
45
  class Actor {
56
- private active?: Promise<void> | undefined;
46
+ private work:
47
+ | { readonly kind: "idle" }
48
+ | { readonly kind: "running"; readonly launch: Launch; readonly task: Promise<void> } = {
49
+ kind: "idle",
50
+ };
57
51
  private stopping = false;
58
52
  private stopDeadline?: NodeJS.Timeout;
59
53
  private nextParentCheck = 0;
@@ -63,7 +57,6 @@ class Actor {
63
57
  private readonly wake = new Wake();
64
58
  // A deferred run waits for an idle session, which has no command-file event.
65
59
  private retry = false;
66
- private current?: Launch | undefined;
67
60
  private readonly errors: string[] = [];
68
61
  private readonly started = new Set<string>();
69
62
  // Actor-local validation metadata only; never retain command prompts or receipt authority.
@@ -151,7 +144,10 @@ class Actor {
151
144
  this.stopping = true;
152
145
  this.wake.raise();
153
146
  write(path.join(this.dir, `stopping-${this.token}.json`), { at: Date.now() });
154
- write(path.join(this.dir, "stop-acknowledged.json"), { at: Date.now(), id: this.current?.id });
147
+ write(path.join(this.dir, "stop-acknowledged.json"), {
148
+ at: Date.now(),
149
+ id: this.work.kind === "running" ? this.work.launch.id : undefined,
150
+ });
155
151
  this.session?.abort().catch((error: unknown) => {
156
152
  this.errors.push(errorText(error));
157
153
  });
@@ -181,29 +177,33 @@ class Actor {
181
177
  if (!session) {
182
178
  throw new Error("Native child session is not ready.");
183
179
  }
184
- this.current = launch;
185
180
  const baseline = new Set(session.sessionManager.getEntries().map((entry) => entry.id));
181
+ const task = Promise.resolve().then(() => this.executeRun(launch, session, baseline));
182
+ this.work = { kind: "running", launch, task };
186
183
  this.observation?.working(true);
187
- this.active = (async () => {
188
- try {
189
- if (
190
- launch.provider !== this.initial.provider ||
191
- launch.model !== this.initial.model ||
192
- launch.thinking !== this.initial.thinking
193
- ) {
194
- throw new Error("Continuation model snapshot changed unexpectedly.");
195
- }
196
- await session.prompt(launch.prompt, { expandPromptTemplates: false });
197
- await session.waitForIdle();
198
- this.saveResult(launch, baseline);
199
- } catch (error) {
200
- this.saveResult(launch, baseline, errorText(error));
201
- } finally {
202
- this.active = undefined;
203
- this.current = undefined;
204
- this.observation?.working(false);
184
+ }
185
+ private async executeRun(
186
+ launch: Launch,
187
+ session: AgentSession,
188
+ baseline: ReadonlySet<string>,
189
+ ): Promise<void> {
190
+ try {
191
+ if (
192
+ launch.provider !== this.initial.provider ||
193
+ launch.model !== this.initial.model ||
194
+ launch.thinking !== this.initial.thinking
195
+ ) {
196
+ throw new Error("Continuation model snapshot changed unexpectedly.");
205
197
  }
206
- })();
198
+ await session.prompt(launch.prompt, { expandPromptTemplates: false });
199
+ await session.waitForIdle();
200
+ this.saveResult(launch, baseline);
201
+ } catch (error) {
202
+ this.saveResult(launch, baseline, errorText(error));
203
+ } finally {
204
+ this.work = { kind: "idle" };
205
+ this.observation?.working(false);
206
+ }
207
207
  }
208
208
  private commandLaunch(command: Command): Launch | undefined {
209
209
  const receipt = path.join(this.dir, "admissions", `${command.id}.json`);
@@ -235,7 +235,7 @@ class Actor {
235
235
  }
236
236
  if (
237
237
  command.action === "run" &&
238
- (this.stopping || this.active !== undefined || !session.isIdle)
238
+ (this.stopping || this.work.kind === "running" || !session.isIdle)
239
239
  ) {
240
240
  this.retry = true;
241
241
  return;
@@ -260,33 +260,31 @@ class Actor {
260
260
  this.applyCommand(command, launch, session);
261
261
  }
262
262
  private staleCommand(command: Command, launch: Launch, session: AgentSession): boolean {
263
- // Unbound legacy Stops cannot safely target this generation either.
264
- if (command.action === "stop") {
265
- return command.actorToken !== this.token;
263
+ switch (command.action) {
264
+ case "stop":
265
+ return command.actorToken !== this.token;
266
+ case "steer":
267
+ return this.work.kind !== "running" || session.isIdle || this.work.launch.id !== launch.id;
268
+ case "run":
269
+ return false;
266
270
  }
267
- return (
268
- command.action === "steer" &&
269
- (this.active === undefined || session.isIdle || this.current?.id !== launch.id)
270
- );
271
271
  }
272
272
  private applyCommand(command: Command, launch: Launch, session: AgentSession): void {
273
- if (command.action === "stop") {
274
- this.stop();
275
- return;
273
+ switch (command.action) {
274
+ case "stop":
275
+ this.stop();
276
+ return;
277
+ case "run":
278
+ this.run(launch);
279
+ return;
280
+ case "steer":
281
+ // Synchronous native queue admission cannot turn into an idle prompt after an await.
282
+ session.agent.steer({
283
+ role: "user",
284
+ content: [{ type: "text", text: command.prompt }],
285
+ timestamp: Date.now(),
286
+ });
276
287
  }
277
- if (command.action === "run") {
278
- this.run(launch);
279
- return;
280
- }
281
- if (command.prompt === undefined) {
282
- throw new Error("Missing guidance.");
283
- }
284
- // Synchronous native queue admission cannot turn into an idle prompt after an await.
285
- session.agent.steer({
286
- role: "user",
287
- content: [{ type: "text", text: command.prompt }],
288
- timestamp: Date.now(),
289
- });
290
288
  }
291
289
  private pendingCommand(file: string): Command | undefined {
292
290
  const identity = commandIdentity(file);
@@ -328,7 +326,7 @@ class Actor {
328
326
  this.errors.push(errorText(error));
329
327
  });
330
328
  }
331
- return this.stopping && this.active === undefined && (this.session?.isIdle ?? true);
329
+ return this.stopping && this.work.kind === "idle" && (this.session?.isIdle ?? true);
332
330
  }
333
331
  /** Durable command readiness, not elapsed time, defines admission. */
334
332
  private async admitCommands(runtime: Runtime, parked: Parking): Promise<void> {
@@ -368,14 +366,14 @@ class Actor {
368
366
  this.parking = await parked.park(runtime, {
369
367
  signal: owner.signal,
370
368
  pi: this.context?.pi,
371
- busy: this.stopping || this.active !== undefined || this.observation?.watched === true,
369
+ busy: this.stopping || this.work.kind === "running" || this.observation?.watched === true,
372
370
  // Raced commands and viewers keep this actor resident; the loop admits after release.
373
371
  quiet: () => {
374
372
  const queued = this.pending().some((command) => this.commandLaunch(command));
375
373
  if (queued) {
376
374
  this.wake.raise();
377
375
  }
378
- const idle = this.active === undefined && !this.stopping;
376
+ const idle = this.work.kind === "idle" && !this.stopping;
379
377
  return !queued && idle && this.observation?.watched !== true;
380
378
  },
381
379
  errors: this.errors,
@@ -386,7 +384,7 @@ class Actor {
386
384
  return this.parking !== "resident";
387
385
  }
388
386
  private failure(error: unknown, unopened?: string): void {
389
- const launch = this.current ?? this.initial;
387
+ const launch = this.work.kind === "running" ? this.work.launch : this.initial;
390
388
  const result: Result = {
391
389
  id: launch.id,
392
390
  owner: launch.owner,
@@ -482,7 +480,9 @@ class Actor {
482
480
  process.on("SIGTERM", this.stop);
483
481
  process.on("SIGINT", this.stop);
484
482
  await this.admitCommands(runtime, parked);
485
- await this.active;
483
+ if (this.work.kind === "running") {
484
+ await this.work.task;
485
+ }
486
486
  } catch (error) {
487
487
  this.failure(error, parked?.opened === true ? undefined : parkedToken);
488
488
  } finally {
@@ -119,18 +119,31 @@ export function latest(base: string, initial: Launch): Launch {
119
119
  export function conversation(base: string, launch: Launch): string {
120
120
  return runDirectory(base, launch.rootId);
121
121
  }
122
+ /** Metadata only: replacing command bytes invalidates the actor's parsed-command cache. */
123
+ export function commandIdentity(file: string): string | undefined {
124
+ try {
125
+ const stat = fs.statSync(file, { bigint: true });
126
+ return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
127
+ } catch (error) {
128
+ if (missing(error)) {
129
+ return;
130
+ }
131
+ throw error;
132
+ }
133
+ }
122
134
  export function enqueue(
123
135
  base: string,
124
136
  launch: Launch,
125
- action: Command["action"],
126
- options: Pick<Command, "prompt" | "actorToken"> = {},
137
+ input:
138
+ | { readonly action: "run" }
139
+ | { readonly action: "steer"; readonly prompt: string }
140
+ | { readonly action: "stop"; readonly actorToken: string },
127
141
  ): string {
128
- const id = action === "run" ? launch.id : randomUUID();
142
+ const id = input.action === "run" ? launch.id : randomUUID();
129
143
  const command: Command = {
130
144
  id,
131
145
  runId: launch.id,
132
- action,
133
- ...options,
146
+ ...input,
134
147
  createdAt: Date.now(),
135
148
  };
136
149
  write(path.join(conversation(base, launch), "commands", `${id}.json`), command, true);
package/src/agent/view.ts CHANGED
@@ -5,7 +5,7 @@ import { readOutputPage } from "../shared/output-page.ts";
5
5
  import { actor, alive, observeAlive } from "./process.ts";
6
6
  import {
7
7
  admissionSchema,
8
- commandSchema,
8
+ commandMetadataSchema,
9
9
  exitSchema,
10
10
  successorSchema,
11
11
  wakeFailureSchema,
@@ -16,9 +16,6 @@ import type { RunsInput } from "./tool-schemas.ts";
16
16
  import { conversation, read, runDirectory } from "./storage.ts";
17
17
  import { published } from "./notifications.ts";
18
18
  import { accountingFields, accountingState, readCanonicalResult, readResult } from "./usage.ts";
19
- import { Type } from "./typebox.ts";
20
-
21
- const commandMetadataSchema = Type.Pick(commandSchema, ["id", "runId", "action", "createdAt"]);
22
19
  interface Guidance {
23
20
  id: string;
24
21
  runId: string;
@@ -34,8 +34,9 @@ import {
34
34
  shouldReplaceDelivery,
35
35
  } from "./snapshots.ts";
36
36
 
37
+ import { ReplaceDeliveries } from "./replace-deliveries.ts";
38
+
37
39
  const PID_PATH = join(getBrokerRuntimeDir(), "broker.pid");
38
- const REPLACE_DELIVERY_DELAY_MS = 1500;
39
40
 
40
41
  interface ConnectedSession extends ParkablePeer {
41
42
  info: SessionInfo;
@@ -45,18 +46,11 @@ interface ConnectedSession extends ParkablePeer {
45
46
  readonly receiptReads: boolean;
46
47
  }
47
48
 
48
- interface PendingReplaceDelivery {
49
- readonly fromId: string;
50
- readonly toId: string;
51
- readonly message: Message;
52
- readonly timer: NodeJS.Timeout;
53
- }
54
-
55
49
  class IntercomBroker {
56
50
  private readonly sessions = new Map<string, ConnectedSession>();
57
51
  private readonly lastMessages = new LastMessageRelay(this.sessions);
58
52
  private readonly parking = new ParkedSessions(this.sessions, this.deliverMessage.bind(this));
59
- private readonly pendingReplaceDeliveries = new Map<string, PendingReplaceDelivery>();
53
+ private readonly replacements = new ReplaceDeliveries(this.deliverMessage.bind(this));
60
54
  private readonly server: net.Server;
61
55
  private shutdownTimer: NodeJS.Timeout | null = null;
62
56
  private readonly socketPath: string;
@@ -201,7 +195,7 @@ class IntercomBroker {
201
195
  this.lastMessages.disconnected(sessionId);
202
196
  if (!this.parking.closed(session)) {
203
197
  this.sessions.delete(sessionId);
204
- this.clearPendingReplaceDeliveries(sessionId);
198
+ this.replacements.disconnected(sessionId);
205
199
  this.broadcast({ type: "session_left", sessionId }, session.info);
206
200
  }
207
201
  this.scheduleShutdownCheck();
@@ -417,10 +411,6 @@ class IntercomBroker {
417
411
  };
418
412
  }
419
413
 
420
- private replaceKey(fromId: string, toId: string, threadId: string): string {
421
- return `${fromId}\0${toId}\0${threadId}`;
422
- }
423
-
424
414
  private queueReplaceDelivery(
425
415
  fromId: string,
426
416
  toId: string,
@@ -431,17 +421,7 @@ class IntercomBroker {
431
421
  if (validationError !== null) {
432
422
  return { id: message.id, accepted: false, delivered: false, reason: validationError };
433
423
  }
434
- const key = this.replaceKey(fromId, toId, message.threadId ?? "");
435
- const existing = this.pendingReplaceDeliveries.get(key);
436
- if (existing) {
437
- clearTimeout(existing.timer);
438
- }
439
- const timer = setTimeout(() => {
440
- this.pendingReplaceDeliveries.delete(key);
441
- this.deliverMessage(toId, from, message);
442
- }, REPLACE_DELIVERY_DELAY_MS);
443
- timer.unref();
444
- this.pendingReplaceDeliveries.set(key, { fromId, toId, message, timer });
424
+ this.replacements.stage(fromId, toId, from, message);
445
425
  return {
446
426
  id: message.id,
447
427
  accepted: true,
@@ -451,18 +431,6 @@ class IntercomBroker {
451
431
  };
452
432
  }
453
433
 
454
- private clearPendingReplaceDeliveries(sessionId: string): void {
455
- for (const [key, pending] of this.pendingReplaceDeliveries) {
456
- if (
457
- pending.toId === sessionId ||
458
- (pending.fromId === sessionId && pending.message.expectsReply === true)
459
- ) {
460
- clearTimeout(pending.timer);
461
- this.pendingReplaceDeliveries.delete(key);
462
- }
463
- }
464
- }
465
-
466
434
  /** One admission rule; retained input is checked as the uncompacted envelope any later
467
435
  * transport generation accepts. */
468
436
  private validateDeliveryPayload(
@@ -4,20 +4,41 @@ import { writeMessage } from "./framing.ts";
4
4
  /** Parking negotiation and its single outstanding broker acknowledgement. */
5
5
  export class ParkRequests {
6
6
  supported = false;
7
- private acknowledged?: ((parked: boolean) => void) | undefined;
7
+ private state:
8
+ | { readonly kind: "idle" }
9
+ | {
10
+ readonly kind: "pending";
11
+ readonly promise: Promise<boolean>;
12
+ readonly resolve: (parked: boolean) => void;
13
+ } = { kind: "idle" };
8
14
  settle(parked: boolean): void {
9
- this.acknowledged?.(parked);
15
+ const state = this.state;
16
+ this.state = { kind: "idle" };
17
+ if (state.kind === "pending") {
18
+ state.resolve(parked);
19
+ }
10
20
  }
11
21
  /** True once the broker retains later input; false when it cannot or the socket closed. */
12
22
  request(socket: () => net.Socket, on: boolean): Promise<boolean> {
13
- return new Promise((resolve) => {
14
- this.acknowledged = on && this.supported ? resolve : undefined;
23
+ if (on && this.state.kind === "pending") {
24
+ return this.state.promise;
25
+ }
26
+ const { promise, resolve, reject } = Promise.withResolvers<boolean>();
27
+ this.settle(false);
28
+ if (on && this.supported) {
29
+ this.state = { kind: "pending", promise, resolve };
30
+ }
31
+ try {
15
32
  if (this.supported) {
16
33
  writeMessage(socket(), { type: on ? "park" : "unpark" });
17
34
  }
18
- if (this.acknowledged === undefined) {
35
+ if (this.state.kind === "idle") {
19
36
  resolve(false);
20
37
  }
21
- });
38
+ } catch (error) {
39
+ this.state = { kind: "idle" };
40
+ reject(error);
41
+ }
42
+ return promise;
22
43
  }
23
44
  }
@@ -63,6 +63,10 @@ function messageDelivery(options: SendOptions): MessageDelivery | undefined {
63
63
  return "steer";
64
64
  }
65
65
 
66
+ type ClientPhase =
67
+ | { readonly kind: "idle" | "connected" }
68
+ | { readonly kind: "connecting" | "closing"; readonly task: Promise<void> };
69
+
66
70
  export class IntercomClient extends EventEmitter {
67
71
  private connection: BrokerConnection | null = null;
68
72
  private _sessionId: string | null = null;
@@ -80,8 +84,7 @@ export class IntercomClient extends EventEmitter {
80
84
  readonly reject: (e: Readonly<Error>) => void;
81
85
  readonly reader: SnapshotReader;
82
86
  }>();
83
- private connecting = false;
84
- private disconnecting = false;
87
+ private phase: ClientPhase = { kind: "idle" };
85
88
  private readonly sendTimeoutMs: number;
86
89
  private readonly listTimeoutMs: number;
87
90
 
@@ -111,13 +114,13 @@ export class IntercomClient extends EventEmitter {
111
114
  isConnected(): boolean {
112
115
  return (
113
116
  (this._sessionId ?? "").length > 0 &&
114
- !this.disconnecting &&
117
+ this.phase.kind !== "closing" &&
115
118
  this.connection?.isActive() === true
116
119
  );
117
120
  }
118
121
 
119
122
  private requireActiveSocket(): net.Socket {
120
- if (this.disconnecting) {
123
+ if (this.phase.kind === "closing") {
121
124
  throw new Error("Client disconnecting");
122
125
  }
123
126
  const connection = this.connection;
@@ -131,60 +134,71 @@ export class IntercomClient extends EventEmitter {
131
134
  }
132
135
 
133
136
  async connect(session: Omit<SessionInfo, "id">, requestedId?: string): Promise<void> {
134
- if (this.connection || this.connecting) {
137
+ if (this.connection || this.phase.kind !== "idle") {
135
138
  throw new Error("Already connected");
136
139
  }
137
- this.connecting = true;
140
+ const task = this.establish(session, requestedId);
141
+ this.phase = { kind: "connecting", task };
138
142
  try {
139
- const socket = await connectBrokerSocket();
140
- const connection = new BrokerConnection(socket, {
141
- message: (message) => this.handleBrokerMessage(message),
142
- error: (error) => {
143
- this.emit("error", error);
144
- },
145
- closed: (error, expected) => {
146
- if (this.connection !== connection) {
147
- return;
148
- }
149
- const established = this._sessionId !== null;
150
- this.connection = null;
151
- this._sessionId = null;
152
- this._topicsSupported = false;
153
- this.parking.settle(false);
154
- this.guardedDeliverySupported = false;
155
- this.wakes.supported = false;
156
- this.disconnecting = false;
157
- this.failPending(error);
158
- if (established && !expected) {
159
- this.emit("disconnected", error);
160
- }
161
- },
162
- });
163
- this.connection = connection;
164
- const { topics, subscriptions, ...identity } = session;
165
- try {
166
- await connection.start({
167
- type: "register",
168
- session: identity,
169
- requestedId,
170
- topicFrames: true,
171
- lastMessages: true,
172
- parking: true,
173
- receiptReads: 1,
174
- guardedDelivery: 1,
175
- ...this.wakes.registration(),
176
- });
177
- } catch (error) {
178
- if (this.connection === connection) {
179
- this.connection = null;
180
- }
181
- throw error;
182
- }
183
- if (this.supportsTopics) {
184
- await this.restoreTopics(topics, subscriptions);
185
- }
143
+ await task;
186
144
  } finally {
187
- this.connecting = false;
145
+ if (this.phase.kind === "connecting" && this.phase.task === task) {
146
+ this.phase = { kind: this.isConnected() ? "connected" : "idle" };
147
+ }
148
+ }
149
+ }
150
+
151
+ private async establish(session: Omit<SessionInfo, "id">, requestedId?: string): Promise<void> {
152
+ const socket = await connectBrokerSocket();
153
+ const connection = new BrokerConnection(socket, {
154
+ message: (message) => this.handleBrokerMessage(message),
155
+ error: (error) => {
156
+ this.emit("error", error);
157
+ },
158
+ closed: (error, expected) => {
159
+ if (this.connection !== connection) {
160
+ return;
161
+ }
162
+ const established = this._sessionId !== null;
163
+ this.connection = null;
164
+ this._sessionId = null;
165
+ this._topicsSupported = false;
166
+ this.parking.settle(false);
167
+ this.guardedDeliverySupported = false;
168
+ this.wakes.supported = false;
169
+ if (this.phase.kind === "connected") {
170
+ this.phase = { kind: "idle" };
171
+ }
172
+ this.failPending(error);
173
+ if (established && !expected) {
174
+ this.emit("disconnected", error);
175
+ }
176
+ },
177
+ });
178
+ this.connection = connection;
179
+ if (this.phase.kind === "closing") {
180
+ await connection.disconnect();
181
+ throw new Error("Client disconnecting");
182
+ }
183
+ const { topics, subscriptions, ...identity } = session;
184
+ try {
185
+ await connection.start({
186
+ type: "register",
187
+ session: identity,
188
+ requestedId,
189
+ topicFrames: true,
190
+ lastMessages: true,
191
+ parking: true,
192
+ receiptReads: 1,
193
+ guardedDelivery: 1,
194
+ ...this.wakes.registration(),
195
+ });
196
+ } catch (error) {
197
+ await connection.disconnect();
198
+ throw error;
199
+ }
200
+ if (this.supportsTopics) {
201
+ await this.restoreTopics(topics, subscriptions);
188
202
  }
189
203
  }
190
204
 
@@ -334,14 +348,30 @@ export class IntercomClient extends EventEmitter {
334
348
  return this.parking.request(() => this.requireActiveSocket(), on);
335
349
  }
336
350
 
337
- async disconnect(): Promise<void> {
338
- const connection = this.connection;
339
- if (!connection) {
340
- return;
351
+ disconnect(): Promise<void> {
352
+ const phase = this.phase;
353
+ if (phase.kind === "closing") {
354
+ return phase.task;
341
355
  }
342
- this.disconnecting = true;
356
+ const connecting = phase.kind === "connecting" ? phase.task : undefined;
357
+ const task = Promise.resolve().then(() => this.close(connecting));
358
+ this.phase = { kind: "closing", task };
359
+ return task;
360
+ }
361
+
362
+ private async close(connecting: Promise<void> | undefined): Promise<void> {
343
363
  this.failPending(new Error("Client disconnected"));
344
- await connection.disconnect();
364
+ try {
365
+ await this.connection?.disconnect();
366
+ try {
367
+ await connecting;
368
+ } catch {
369
+ // The connect caller owns acquisition failure; close still joins any acquired socket.
370
+ }
371
+ await this.connection?.disconnect();
372
+ } finally {
373
+ this.phase = { kind: "idle" };
374
+ }
345
375
  }
346
376
 
347
377
  listSessions(): Promise<SessionInfo[]> {
@@ -495,7 +525,7 @@ export class IntercomClient extends EventEmitter {
495
525
  readonly subscriptions?: SessionInfo["subscriptions"] | undefined;
496
526
  readonly topics?: readonly TopicUpdate[] | undefined;
497
527
  }): void {
498
- if (this.disconnecting) {
528
+ if (this.phase.kind === "closing") {
499
529
  return;
500
530
  }
501
531