@fitchmultz/pi-subagents 0.52.20 → 0.52.22

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 (48) hide show
  1. package/CHANGELOG.md +46 -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/bridge-wake-state.js +31 -6
  13. package/dist/pi-intercom/bridge-wake.js +4 -10
  14. package/dist/pi-intercom/broker/broker.js +4 -26
  15. package/dist/pi-intercom/broker/client-park.d.ts +1 -1
  16. package/dist/pi-intercom/broker/client-park.js +22 -6
  17. package/dist/pi-intercom/broker/client.d.ts +3 -2
  18. package/dist/pi-intercom/broker/client.js +88 -62
  19. package/dist/pi-intercom/broker/connection.d.ts +3 -5
  20. package/dist/pi-intercom/broker/connection.js +48 -33
  21. package/dist/pi-intercom/broker/framing.d.ts +1 -1
  22. package/dist/pi-intercom/broker/framing.js +19 -12
  23. package/dist/pi-intercom/broker/replace-deliveries.js +38 -0
  24. package/dist/pi-intercom/connection.js +5 -8
  25. package/dist/pi-intercom/inbound-journal.js +5 -2
  26. package/dist/shared/journal-frames.js +19 -24
  27. package/docs/development.md +6 -0
  28. package/package.json +1 -1
  29. package/src/agent/contracts.ts +12 -3
  30. package/src/agent/index.ts +10 -10
  31. package/src/agent/legacy.ts +5 -5
  32. package/src/agent/notifications.ts +35 -15
  33. package/src/agent/parent-check.ts +40 -0
  34. package/src/agent/process.ts +53 -55
  35. package/src/agent/runner.ts +65 -65
  36. package/src/agent/storage.ts +18 -5
  37. package/src/agent/view.ts +1 -4
  38. package/src/pi-intercom/bridge-wake-state.ts +39 -8
  39. package/src/pi-intercom/bridge-wake.ts +4 -9
  40. package/src/pi-intercom/broker/broker.ts +5 -37
  41. package/src/pi-intercom/broker/client-park.ts +27 -6
  42. package/src/pi-intercom/broker/client.ts +91 -61
  43. package/src/pi-intercom/broker/connection.ts +58 -39
  44. package/src/pi-intercom/broker/framing.ts +24 -12
  45. package/src/pi-intercom/broker/replace-deliveries.ts +56 -0
  46. package/src/pi-intercom/connection.ts +9 -9
  47. package/src/pi-intercom/inbound-journal.ts +5 -2
  48. 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;
@@ -24,6 +24,13 @@ function integer(value: unknown): number {
24
24
  return value;
25
25
  }
26
26
 
27
+ type WakeReservation =
28
+ | { readonly kind: "deferred"; readonly until: number }
29
+ | { readonly kind: "reserved"; readonly post: (action: () => Promise<void>) => Promise<void> };
30
+ type AttemptPhase =
31
+ | { readonly kind: "idle" | "posting" | "settlementPending" | "closed" }
32
+ | Extract<WakeReservation, { readonly kind: "reserved" }>;
33
+
27
34
  /** One helper ledger, independent of identity, certificate, URL and credential rotation. */
28
35
  export class WakeState {
29
36
  private readonly db: DatabaseSync;
@@ -32,7 +39,7 @@ export class WakeState {
32
39
  private readonly inode: number;
33
40
  private readonly device: number;
34
41
  private readonly owner: WakeOwner;
35
- private finishing = false;
42
+ private phase: AttemptPhase = { kind: "idle" };
36
43
 
37
44
  constructor(stateDir: string) {
38
45
  this.stateDir = stateDir;
@@ -212,14 +219,37 @@ export class WakeState {
212
219
  this.db.prepare("DELETE FROM wake_batches WHERE bucket=?").run(bucket);
213
220
  }
214
221
 
215
- /** Persist quota and uncertainty BEFORE dispatch. Returning a deadline means no attempt admitted. */
216
- reserve(bucket: string): number | undefined {
222
+ /** Persist quota and uncertainty before exposing a single-use POST reservation. */
223
+ reserve(bucket: string): WakeReservation {
217
224
  this.retrySettlement();
218
- return this.transaction(() => this.reservation(bucket));
225
+ if (this.phase.kind !== "idle") {
226
+ throw new Error("A wake reservation is already active or its owner is closed.");
227
+ }
228
+ const until = this.transaction(() => this.reservation(bucket));
229
+ if (until !== undefined) {
230
+ return { kind: "deferred", until };
231
+ }
232
+ const reservation: Extract<WakeReservation, { readonly kind: "reserved" }> = {
233
+ kind: "reserved",
234
+ post: async (action) => {
235
+ if (this.phase !== reservation) {
236
+ throw new Error("This wake reservation is no longer available.");
237
+ }
238
+ this.phase = { kind: "posting" };
239
+ try {
240
+ await action();
241
+ } finally {
242
+ // Completion, rejection and abort all start the durable expiry window here.
243
+ this.settle();
244
+ }
245
+ },
246
+ };
247
+ this.phase = reservation;
248
+ return reservation;
219
249
  }
220
250
 
221
251
  retrySettlement(): void {
222
- if (this.finishing) {
252
+ if (this.phase.kind === "settlementPending") {
223
253
  this.settle();
224
254
  }
225
255
  }
@@ -238,10 +268,10 @@ export class WakeState {
238
268
  }
239
269
 
240
270
  /** A failed settlement stays open and must be retried before this owner can dispatch again. */
241
- settle(): void {
242
- this.finishing = true;
271
+ private settle(): void {
272
+ this.phase = { kind: "settlementPending" };
243
273
  this.transaction(() => this.finishOwner(this.owner.name));
244
- this.finishing = false;
274
+ this.phase = { kind: "idle" };
245
275
  }
246
276
 
247
277
  private finishOwner(owner: string): void {
@@ -314,6 +344,7 @@ export class WakeState {
314
344
  try {
315
345
  this.settle();
316
346
  } finally {
347
+ this.phase = { kind: "closed" };
317
348
  this.db.close();
318
349
  this.owner.close();
319
350
  }
@@ -211,19 +211,14 @@ export class WakeDeliveries {
211
211
  next = Math.min(next, due);
212
212
  continue;
213
213
  }
214
- const deferred = state.reserve(key);
215
- if (deferred !== undefined) {
216
- next = Math.min(next, deferred);
214
+ const reservation = state.reserve(key);
215
+ if (reservation.kind === "deferred") {
216
+ next = Math.min(next, reservation.until);
217
217
  continue;
218
218
  }
219
219
  // ponytail: two cold Macs can each admit one wake. Literal cross-Mac single admission
220
220
  // requires a neutral quota authority governing every writer, not a URL hash or HTTP 2xx.
221
- try {
222
- await this.post(candidate);
223
- } finally {
224
- // Expiry starts after response/error/abort, never before COMMIT or connect/TLS.
225
- state.settle();
226
- }
221
+ await reservation.post(() => this.post(candidate));
227
222
  // Re-read ACKs and all groups after transport I/O; a snapshot must not wake already consumed mail.
228
223
  return Date.now();
229
224
  }
@@ -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
  }