@fitchmultz/pi-subagents 0.52.18 → 0.52.20

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 (57) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +4 -0
  3. package/dist/agent/host.js +12 -0
  4. package/dist/agent/index.js +70 -56
  5. package/dist/agent/launch.js +12 -3
  6. package/dist/agent/notifications.js +5 -5
  7. package/dist/agent/parking.js +26 -15
  8. package/dist/agent/runner.js +18 -16
  9. package/dist/agent/tool-schemas.js +2 -0
  10. package/dist/agent/usage.js +42 -3
  11. package/dist/agent/view.js +13 -13
  12. package/dist/agent/wake.js +17 -15
  13. package/dist/build-info.js +1 -1
  14. package/dist/pi-intercom/bridge-presence.js +30 -18
  15. package/dist/pi-intercom/bridge-wake.js +60 -37
  16. package/dist/pi-intercom/broker/broker.js +2 -2
  17. package/dist/pi-intercom/broker/client-last.js +6 -18
  18. package/dist/pi-intercom/broker/client.js +16 -50
  19. package/dist/pi-intercom/broker/pending-requests.d.ts +12 -0
  20. package/dist/pi-intercom/broker/pending-requests.js +26 -0
  21. package/dist/pi-intercom/broker/snapshot-reader.d.ts +3 -2
  22. package/dist/pi-intercom/broker/snapshot-reader.js +34 -15
  23. package/dist/pi-intercom/broker/snapshots.d.ts +25 -7
  24. package/dist/pi-intercom/broker/snapshots.js +56 -25
  25. package/dist/pi-intercom/broker/spawn-claim.js +89 -0
  26. package/dist/pi-intercom/broker/spawn.js +31 -78
  27. package/dist/pi-intercom/lifecycle.js +26 -26
  28. package/dist/shared/post-exit-stdio-guard.js +44 -37
  29. package/docs/development.md +25 -0
  30. package/docs/intercom.md +13 -0
  31. package/docs/reference.md +16 -5
  32. package/package.json +1 -1
  33. package/scripts/build.mjs +41 -14
  34. package/skills/pi-subagents/SKILL.md +8 -4
  35. package/src/agent/contracts.ts +17 -0
  36. package/src/agent/host.ts +19 -0
  37. package/src/agent/index.ts +91 -62
  38. package/src/agent/launch.ts +16 -3
  39. package/src/agent/notifications.ts +5 -5
  40. package/src/agent/parking.ts +39 -17
  41. package/src/agent/runner.ts +20 -21
  42. package/src/agent/tool-schemas.ts +2 -0
  43. package/src/agent/usage.ts +48 -3
  44. package/src/agent/view.ts +15 -13
  45. package/src/agent/wake.ts +19 -15
  46. package/src/pi-intercom/bridge-presence.ts +37 -18
  47. package/src/pi-intercom/bridge-wake.ts +70 -38
  48. package/src/pi-intercom/broker/broker.ts +2 -1
  49. package/src/pi-intercom/broker/client-last.ts +14 -25
  50. package/src/pi-intercom/broker/client.ts +36 -65
  51. package/src/pi-intercom/broker/pending-requests.ts +37 -0
  52. package/src/pi-intercom/broker/snapshot-reader.ts +44 -16
  53. package/src/pi-intercom/broker/snapshots.ts +82 -37
  54. package/src/pi-intercom/broker/spawn-claim.ts +98 -0
  55. package/src/pi-intercom/broker/spawn.ts +42 -82
  56. package/src/pi-intercom/lifecycle.ts +38 -27
  57. package/src/shared/post-exit-stdio-guard.ts +48 -40
@@ -7,6 +7,37 @@ import { isRecord } from "../shared/unknown.js";
7
7
  import { Assert } from "./typebox.js";
8
8
  import { contributionsSchema, resultSchema, } from "./contracts.js";
9
9
  import { read, runDirectory, write } from "./storage.js";
10
+ /** Pending repair remains authoritative until its native terminal is published and validated. */
11
+ export function accountingState(result) {
12
+ if (result?.accountingPending !== undefined) {
13
+ return { kind: "pending", pending: result.accountingPending, error: result.accountingError };
14
+ }
15
+ const terminalEntryId = result?.terminalEntryId;
16
+ if (result?.contributions !== undefined) {
17
+ return { kind: "verified", contributions: result.contributions, terminalEntryId };
18
+ }
19
+ return result?.accountingError === undefined
20
+ ? { kind: "unrecorded", terminalEntryId }
21
+ : { kind: "unavailable", error: result.accountingError, terminalEntryId };
22
+ }
23
+ export function accountingFields(state) {
24
+ const terminal = state.kind !== "pending" && state.terminalEntryId !== undefined
25
+ ? { terminalEntryId: state.terminalEntryId }
26
+ : {};
27
+ switch (state.kind) {
28
+ case "unrecorded":
29
+ return terminal;
30
+ case "verified":
31
+ return { contributions: state.contributions, ...terminal };
32
+ case "pending":
33
+ return {
34
+ accountingPending: state.pending,
35
+ ...(state.error === undefined ? {} : { accountingError: state.error }),
36
+ };
37
+ case "unavailable":
38
+ return { accountingError: state.error, ...terminal };
39
+ }
40
+ }
10
41
  /** Billing evidence of one native entry. Missing required usage is an error only outside a baseline. */
11
42
  function billing(entry) {
12
43
  if (entry.checkpoint === true) {
@@ -143,10 +174,11 @@ export function readResult(base, launch) {
143
174
  if (!result) {
144
175
  return;
145
176
  }
146
- const pending = result.accountingPending;
147
- if (pending === undefined) {
177
+ const accounting = accountingState(result);
178
+ if (accounting.kind !== "pending") {
148
179
  return result;
149
180
  }
181
+ const { pending } = accounting;
150
182
  let contributions;
151
183
  try {
152
184
  contributions = childUsage(launch, new Set(pending.baseline), pending.terminalCandidate);
@@ -156,7 +188,14 @@ export function readResult(base, launch) {
156
188
  return result;
157
189
  }
158
190
  const { accountingPending: _pending, accountingError: _error, ...execution } = result;
159
- const repaired = { ...execution, terminalEntryId: pending.terminalCandidate, contributions };
191
+ const repaired = {
192
+ ...execution,
193
+ ...accountingFields({
194
+ kind: "verified",
195
+ terminalEntryId: pending.terminalCandidate,
196
+ contributions,
197
+ }),
198
+ };
160
199
  write(path.join(runDirectory(base, launch.id), "result.json"), repaired);
161
200
  return repaired;
162
201
  }
@@ -5,25 +5,25 @@ import { actor, alive, observeAlive } from "./process.js";
5
5
  import { admissionSchema, commandSchema, exitSchema, successorSchema, wakeFailureSchema, } from "./contracts.js";
6
6
  import { conversation, read, runDirectory } from "./storage.js";
7
7
  import { published } from "./notifications.js";
8
- import { readCanonicalResult, readResult } from "./usage.js";
8
+ import { accountingFields, accountingState, readCanonicalResult, readResult } from "./usage.js";
9
9
  import { Type } from "./typebox.js";
10
10
  const commandMetadataSchema = Type.Pick(commandSchema, ["id", "runId", "action", "createdAt"]);
11
11
  function toolResult(value, text) {
12
12
  return { content: [{ type: "text", text }], details: value };
13
13
  }
14
14
  function receiptResult(result) {
15
- return result === undefined
16
- ? undefined
17
- : {
18
- id: result.id,
19
- owner: result.owner,
20
- status: result.status,
21
- endedAt: result.endedAt,
22
- terminalEntryId: result.terminalEntryId,
23
- error: result.error,
24
- accountingError: result.accountingError,
25
- accountingPending: result.accountingPending,
26
- };
15
+ if (result === undefined) {
16
+ return;
17
+ }
18
+ const { contributions: _contributions, ...accounting } = accountingFields(accountingState(result));
19
+ return {
20
+ id: result.id,
21
+ owner: result.owner,
22
+ status: result.status,
23
+ endedAt: result.endedAt,
24
+ error: result.error,
25
+ ...accounting,
26
+ };
27
27
  }
28
28
  /** `observed` holds batched liveness by actor token; any other actor gets a fresh check. */
29
29
  function processView(base, launch, observed) {
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs";
2
+ import assert from "node:assert/strict";
2
3
  import { journalStamp } from "../shared/native-journal.js";
3
4
  import { missing } from "./storage.js";
4
5
  /** Identity and change stamp of a path; "" when absent. */
@@ -13,10 +14,10 @@ export function pathStamp(file) {
13
14
  }
14
15
  /** Native change hints for one directory identity; owners still reconcile canonical files. */
15
16
  export class DirectoryWatch {
16
- watcher;
17
- armed = "";
17
+ state = {
18
+ kind: "unarmed",
19
+ };
18
20
  checked = "";
19
- failed = false;
20
21
  directory;
21
22
  relevant;
22
23
  wake;
@@ -30,41 +31,41 @@ export class DirectoryWatch {
30
31
  const identity = stat?.isDirectory() === true ? `${stat.dev}:${stat.ino}` : "";
31
32
  const checked = this.checked;
32
33
  this.checked = stat === undefined ? "" : `${identity}:${stat.mtimeNs}:${stat.ctimeNs}`;
33
- if (this.watcher !== undefined && identity === this.armed) {
34
+ if (this.state.kind === "armed" && identity === this.state.identity) {
34
35
  return checked === this.checked ? "watching" : "changed";
35
36
  }
36
37
  // A replaced directory leaves an inode watch silent on Linux.
37
38
  this.close();
38
- return identity === "" || this.failed ? "unwatched" : this.arm(identity);
39
+ return identity === "" || this.state.kind === "polling" ? "unwatched" : this.arm(identity);
39
40
  }
40
41
  arm(identity) {
41
42
  try {
42
43
  const watcher = fs.watch(this.directory, { persistent: false }, (_event, file) => {
43
- if (watcher === this.watcher && this.relevant(file)) {
44
+ if (this.state.kind === "armed" && watcher === this.state.watcher && this.relevant(file)) {
44
45
  this.wake();
45
46
  }
46
47
  });
47
48
  watcher.on("error", () => {
48
- if (watcher === this.watcher) {
49
- this.failed = true;
50
- this.close();
49
+ if (this.state.kind === "armed" && watcher === this.state.watcher) {
50
+ watcher.close();
51
+ this.state = { kind: "polling" };
51
52
  this.wake();
52
53
  }
53
54
  });
54
- this.watcher = watcher;
55
- this.armed = identity;
55
+ this.state = { kind: "armed", watcher, identity };
56
56
  return "armed";
57
57
  }
58
58
  catch (error) {
59
59
  // Unsupported platforms and watch limits keep their owner's polling fallback.
60
- this.failed = !missing(error);
60
+ this.state = { kind: missing(error) ? "unarmed" : "polling" };
61
61
  return "unwatched";
62
62
  }
63
63
  }
64
64
  close() {
65
- this.watcher?.close();
66
- this.watcher = undefined;
67
- this.armed = "";
65
+ if (this.state.kind === "armed") {
66
+ this.state.watcher.close();
67
+ this.state = { kind: "unarmed" };
68
+ }
68
69
  }
69
70
  }
70
71
  /** A coalesced wake for one owner loop; raised until that loop takes it. */
@@ -82,6 +83,7 @@ export class Wake {
82
83
  }
83
84
  /** Returns at once for a wake raised since the last take. */
84
85
  async sleep(ms) {
86
+ assert.equal(this.resolve, undefined, "A Wake belongs to one waiting owner loop.");
85
87
  if (this.raised) {
86
88
  return;
87
89
  }
@@ -1 +1 @@
1
- export const EXTENSION_BUILD = Object.freeze({"version":"0.52.18","sha256":"83db07f9acbdd6ff5985c46e1f5f5411f68b6766697a2dfab019b3d00dbba1c1"});
1
+ export const EXTENSION_BUILD = Object.freeze({"version":"0.52.20","sha256":"6698668f485ef551faf914031fee7ad7165c9062a38c31b2827077d8e93e94b9"});
@@ -4,9 +4,7 @@ import { readBridgeFile, writeBridgeJson } from "./bridge-files.js";
4
4
  import { log } from "./bridge-protocol.js";
5
5
  export class PersistentPresence {
6
6
  registrations = new Map();
7
- stopped = false;
8
- timer;
9
- running;
7
+ phase = { kind: "idle" };
10
8
  owner;
11
9
  constructor(owner) {
12
10
  this.owner = owner;
@@ -61,21 +59,28 @@ export class PersistentPresence {
61
59
  this.start();
62
60
  }
63
61
  start() {
64
- if (!this.stopped && !this.running) {
65
- clearTimeout(this.timer);
66
- this.timer = setTimeout(() => {
67
- this.running = this.maintain().finally(() => {
68
- this.running = undefined;
69
- if (!this.stopped) {
70
- this.timer = setTimeout(() => this.start(), 15000);
71
- }
72
- });
73
- }, 0);
62
+ if (this.phase.kind === "running" || this.phase.kind === "stopped") {
63
+ return;
64
+ }
65
+ this.schedule(0);
66
+ }
67
+ schedule(delay) {
68
+ if (this.phase.kind === "scheduled") {
69
+ clearTimeout(this.phase.timer);
74
70
  }
71
+ const timer = setTimeout(() => {
72
+ const task = this.maintain().finally(() => {
73
+ if (this.phase.kind !== "stopped") {
74
+ this.schedule(15000);
75
+ }
76
+ });
77
+ this.phase = { kind: "running", task };
78
+ }, delay);
79
+ this.phase = { kind: "scheduled", timer };
75
80
  }
76
81
  async maintain() {
77
82
  for (const registration of this.registrations.values()) {
78
- if (this.stopped) {
83
+ if (this.phase.kind === "stopped") {
79
84
  return;
80
85
  }
81
86
  const identity = this.owner.clients().get(registration.fingerprint);
@@ -95,9 +100,16 @@ export class PersistentPresence {
95
100
  }
96
101
  }
97
102
  }
98
- async stop() {
99
- this.stopped = true;
100
- clearTimeout(this.timer);
101
- await this.running;
103
+ stop() {
104
+ const phase = this.phase;
105
+ if (phase.kind === "stopped") {
106
+ return phase.task;
107
+ }
108
+ if (phase.kind === "scheduled") {
109
+ clearTimeout(phase.timer);
110
+ }
111
+ const task = phase.kind === "running" ? phase.task : Promise.resolve();
112
+ this.phase = { kind: "stopped", task };
113
+ return task;
102
114
  }
103
115
  }
@@ -22,9 +22,7 @@ export class WakeDeliveries {
22
22
  allowed;
23
23
  deliveries = new Map();
24
24
  controller = new AbortController();
25
- timer;
26
- running;
27
- dirty = false;
25
+ phase = { kind: "idle" };
28
26
  storageBackoff = 15000;
29
27
  constructor(stateDir, allowed) {
30
28
  this.stateDir = stateDir;
@@ -62,11 +60,17 @@ export class WakeDeliveries {
62
60
  catch {
63
61
  // The scheduled storage recovery fails closed; never let an event bypass durable admission.
64
62
  }
65
- if (this.running) {
66
- this.dirty = true;
67
- }
68
- else {
69
- this.schedule(Date.now());
63
+ const phase = this.phase;
64
+ switch (phase.kind) {
65
+ case "running":
66
+ this.phase = { kind: "running-dirty", task: phase.task };
67
+ return;
68
+ case "running-dirty":
69
+ case "stopped":
70
+ return;
71
+ case "idle":
72
+ case "scheduled":
73
+ this.schedule(Date.now());
70
74
  }
71
75
  }
72
76
  reconcile() {
@@ -80,28 +84,36 @@ export class WakeDeliveries {
80
84
  }
81
85
  }
82
86
  schedule(deadline) {
83
- clearTimeout(this.timer);
84
- this.timer = setTimeout(() => {
85
- this.dirty = false;
86
- let next = Infinity;
87
- this.running = this.deliver()
88
- .then((due) => {
89
- next = due;
90
- this.storageBackoff = 15000;
87
+ if (this.phase.kind === "scheduled") {
88
+ clearTimeout(this.phase.timer);
89
+ }
90
+ const timer = setTimeout(() => this.startDelivery(), Math.max(0, Math.min(deadline - Date.now(), 0x7fffffff)));
91
+ this.phase = { kind: "scheduled", timer };
92
+ }
93
+ startDelivery() {
94
+ let next = Infinity;
95
+ const task = this.deliver()
96
+ .then((due) => {
97
+ next = due;
98
+ this.storageBackoff = 15000;
99
+ return;
100
+ })
101
+ .catch(() => {
102
+ log("wake_delivery", { result: "storage_unavailable" });
103
+ next = Date.now() + this.storageBackoff;
104
+ this.storageBackoff = Math.min(this.storageBackoff * 2, 300000);
105
+ })
106
+ .finally(() => {
107
+ if (this.phase.kind === "stopped") {
91
108
  return;
92
- })
93
- .catch(() => {
94
- log("wake_delivery", { result: "storage_unavailable" });
95
- next = Date.now() + this.storageBackoff;
96
- this.storageBackoff = Math.min(this.storageBackoff * 2, 300000);
97
- })
98
- .finally(() => {
99
- this.running = undefined;
100
- if (!this.controller.signal.aborted && (this.dirty || Number.isFinite(next))) {
101
- this.schedule(this.dirty ? Date.now() : next);
102
- }
103
- });
104
- }, Math.max(0, Math.min(deadline - Date.now(), 0x7fffffff)));
109
+ }
110
+ const dirty = this.phase.kind === "running-dirty";
111
+ this.phase = { kind: "idle" };
112
+ if (dirty || Number.isFinite(next)) {
113
+ this.schedule(dirty ? Date.now() : next);
114
+ }
115
+ });
116
+ this.phase = { kind: "running", task };
105
117
  }
106
118
  authorized(delivery) {
107
119
  return (!this.controller.signal.aborted &&
@@ -233,14 +245,25 @@ export class WakeDeliveries {
233
245
  }
234
246
  this.changed(false);
235
247
  }
236
- async stop() {
237
- this.controller.abort();
238
- clearTimeout(this.timer);
239
- await this.running;
240
- for (const delivery of this.deliveries.values()) {
241
- delivery.mailbox.close();
248
+ stop() {
249
+ const phase = this.phase;
250
+ if (phase.kind === "stopped") {
251
+ return phase.task;
252
+ }
253
+ if (phase.kind === "scheduled") {
254
+ clearTimeout(phase.timer);
242
255
  }
243
- this.deliveries.clear();
244
- this.state?.close();
256
+ const running = phase.kind === "running" || phase.kind === "running-dirty" ? phase.task : undefined;
257
+ const task = Promise.resolve(running).then(() => {
258
+ for (const delivery of this.deliveries.values()) {
259
+ delivery.mailbox.close();
260
+ }
261
+ this.deliveries.clear();
262
+ this.state?.close();
263
+ return;
264
+ });
265
+ this.phase = { kind: "stopped", task };
266
+ this.controller.abort();
267
+ return task;
245
268
  }
246
269
  }
@@ -13,7 +13,7 @@ import { acceptBrokerConnection, registeredSessionInfo } from "./server-connecti
13
13
  import { guardedDeliveryError } from "./guard.js";
14
14
  import { compactTopicMessage, normalizeMessage, normalizeSessionInfo, } from "../types.js";
15
15
  import { errorMessage, isRecord } from "../../shared/unknown.js";
16
- import { messageSender, snapshotFrames, TopicSnapshot, publicationMessage, deliveryAcknowledgement, shouldReplaceDelivery, } from "./snapshots.js";
16
+ import { messageSender, snapshotFrames, TopicSnapshot, publicationMessage, preparedPublication, deliveryAcknowledgement, shouldReplaceDelivery, } from "./snapshots.js";
17
17
  const PID_PATH = join(getBrokerRuntimeDir(), "broker.pid");
18
18
  const REPLACE_DELIVERY_DELAY_MS = 1500;
19
19
  class IntercomBroker {
@@ -192,7 +192,7 @@ class IntercomBroker {
192
192
  ? session.info
193
193
  : session.topics.snapshot(session.info, prepared);
194
194
  const from = messageSender(nextInfo);
195
- const deliveries = this.planPublications(currentId, from, prepared.publication);
195
+ const deliveries = this.planPublications(currentId, from, preparedPublication(prepared));
196
196
  // Validate every frame before committing state or delivering publications.
197
197
  if (prepared.restore) {
198
198
  session.topics.validateRestore(session.info, prepared, requestId);
@@ -3,6 +3,7 @@ import { errorMessage } from "../../shared/unknown.js";
3
3
  import { isNativeReadQuery, isNativeReadResult, isLastMessageResult, isReceiptResult, } from "./last.js";
4
4
  import { normalizeSessionInfo, } from "../types.js";
5
5
  import { writeMessage } from "./framing.js";
6
+ import { PendingRequests } from "./pending-requests.js";
6
7
  /** A peer's read of this session or, for a main, of its exited `parked` child. */
7
8
  export function lastRequest(msg) {
8
9
  const from = normalizeSessionInfo(msg.from);
@@ -24,26 +25,20 @@ export class LastMessageRequests {
24
25
  constructor(timeoutMs) {
25
26
  this.timeoutMs = timeoutMs;
26
27
  }
27
- pending = new Map();
28
+ pending = new PendingRequests();
28
29
  fail(error) {
29
30
  this.lastMessages = false;
30
31
  this.receiptReads = false;
31
- for (const pending of this.pending.values()) {
32
- clearTimeout(pending.timer);
33
- pending.reject(error);
34
- }
35
- this.pending.clear();
32
+ this.pending.fail(error);
36
33
  }
37
34
  receive(message) {
38
35
  if (typeof message.requestId !== "string" || !isNativeReadResult(message.result)) {
39
36
  throw new Error("Invalid last-message response");
40
37
  }
41
- const pending = this.pending.get(message.requestId);
38
+ const pending = this.pending.take(message.requestId);
42
39
  if (!pending) {
43
40
  return;
44
41
  }
45
- clearTimeout(pending.timer);
46
- this.pending.delete(message.requestId);
47
42
  pending.resolve(message.result);
48
43
  }
49
44
  async last(socket, to, query) {
@@ -66,19 +61,12 @@ export class LastMessageRequests {
66
61
  }
67
62
  return new Promise((resolve, reject) => {
68
63
  const requestId = randomUUID();
69
- const timer = setTimeout(() => {
70
- this.pending.delete(requestId);
71
- reject(new Error("Last-message read timeout"));
72
- }, this.timeoutMs);
73
- timer.unref();
74
- this.pending.set(requestId, { resolve, reject, timer });
64
+ this.pending.set(requestId, { resolve, reject }, this.timeoutMs, () => new Error("Last-message read timeout"));
75
65
  try {
76
66
  writeMessage(socket, { type: "last_request", requestId, to, query });
77
67
  }
78
68
  catch (error) {
79
- clearTimeout(timer);
80
- this.pending.delete(requestId);
81
- reject(new Error(errorMessage(error)));
69
+ this.pending.take(requestId)?.reject(new Error(errorMessage(error)));
82
70
  }
83
71
  });
84
72
  }
@@ -3,6 +3,7 @@ import { randomUUID } from "crypto";
3
3
  import { writeMessage } from "./framing.js";
4
4
  import { BrokerConnection, connectBrokerSocket } from "./connection.js";
5
5
  import { SnapshotReader } from "./snapshot-reader.js";
6
+ import { PendingRequests } from "./pending-requests.js";
6
7
  import { LastMessageRequests, lastRequest } from "./client-last.js";
7
8
  import { ParkRequests } from "./client-park.js";
8
9
  import { WakeMessages } from "./client-wake.js";
@@ -36,8 +37,8 @@ export class IntercomClient extends EventEmitter {
36
37
  wakes = new WakeMessages(this, () => this.requireActiveSocket());
37
38
  guardedDeliverySupported = false;
38
39
  lastRequests;
39
- pendingSends = new Map();
40
- pendingLists = new Map();
40
+ pendingSends = new PendingRequests();
41
+ pendingLists = new PendingRequests();
41
42
  connecting = false;
42
43
  disconnecting = false;
43
44
  sendTimeoutMs;
@@ -50,14 +51,8 @@ export class IntercomClient extends EventEmitter {
50
51
  }
51
52
  failPending(error) {
52
53
  this.lastRequests.fail(error);
53
- for (const pending of this.pendingSends.values()) {
54
- pending.reject(error);
55
- }
56
- this.pendingSends.clear();
57
- for (const pending of this.pendingLists.values()) {
58
- pending.reject(error);
59
- }
60
- this.pendingLists.clear();
54
+ this.pendingSends.fail(error);
55
+ this.pendingLists.fail(error);
61
56
  }
62
57
  get sessionId() {
63
58
  return this._sessionId;
@@ -240,12 +235,11 @@ export class IntercomClient extends EventEmitter {
240
235
  if (type !== "delivered" && typeof reason !== "string") {
241
236
  throw new Error(`Invalid ${errorMessage(type)} message`);
242
237
  }
243
- const pending = this.pendingSends.get(messageId);
238
+ const pending = this.pendingSends.take(messageId);
244
239
  // Late responses are harmless after the caller has timed out.
245
240
  if (!pending) {
246
241
  return;
247
242
  }
248
- this.pendingSends.delete(messageId);
249
243
  pending.resolve({
250
244
  id: messageId,
251
245
  accepted: type !== "delivery_failed",
@@ -266,14 +260,12 @@ export class IntercomClient extends EventEmitter {
266
260
  return;
267
261
  }
268
262
  if (typeof error === "string") {
269
- this.pendingLists.delete(requestId);
270
- pending.reject(new Error(error));
263
+ this.pendingLists.take(requestId)?.reject(new Error(error));
271
264
  return;
272
265
  }
273
266
  pending.reader.append(sessions, receipts);
274
267
  if (more !== true) {
275
- this.pendingLists.delete(requestId);
276
- pending.resolve(pending.reader.finish());
268
+ this.pendingLists.take(requestId)?.resolve(pending.reader.finish());
277
269
  }
278
270
  }
279
271
  park(on = true) {
@@ -319,8 +311,8 @@ export class IntercomClient extends EventEmitter {
319
311
  }
320
312
  return new Promise((resolve, reject) => {
321
313
  const requestId = randomUUID();
314
+ const reader = new SnapshotReader();
322
315
  const wrappedResolve = (snapshot) => {
323
- clearTimeout(timeout);
324
316
  try {
325
317
  // Commit confirmed subscriptions before handling a following message in the same read.
326
318
  onAccepted?.(snapshot);
@@ -331,21 +323,14 @@ export class IntercomClient extends EventEmitter {
331
323
  }
332
324
  };
333
325
  const wrappedReject = (error) => {
334
- clearTimeout(timeout);
326
+ reader.fail();
335
327
  reject(error);
336
328
  };
337
- const timeout = setTimeout(() => {
338
- if (this.pendingLists.has(requestId)) {
339
- this.pendingLists.delete(requestId);
340
- wrappedReject(Object.assign(new Error("List sessions timeout"), { code: "list_timeout" }));
341
- }
342
- }, this.listTimeoutMs);
343
- timeout.unref();
344
329
  this.pendingLists.set(requestId, {
345
330
  resolve: wrappedResolve,
346
331
  reject: wrappedReject,
347
- reader: new SnapshotReader(),
348
- });
332
+ reader,
333
+ }, this.listTimeoutMs, () => Object.assign(new Error("List sessions timeout"), { code: "list_timeout" }));
349
334
  try {
350
335
  writeMessage(socket, {
351
336
  type: "list",
@@ -355,9 +340,7 @@ export class IntercomClient extends EventEmitter {
355
340
  });
356
341
  }
357
342
  catch (error) {
358
- clearTimeout(timeout);
359
- this.pendingLists.delete(requestId);
360
- reject(toError(error));
343
+ this.pendingLists.take(requestId)?.reject(toError(error));
361
344
  }
362
345
  });
363
346
  }
@@ -377,7 +360,7 @@ export class IntercomClient extends EventEmitter {
377
360
  return Promise.reject(toError(error));
378
361
  }
379
362
  const messageId = options.messageId ?? (options.guard ? "" : randomUUID());
380
- if (this.pendingSends.has(messageId)) {
363
+ if (this.pendingSends.get(messageId)) {
381
364
  return Promise.reject(new Error("Message ID already has a pending send"));
382
365
  }
383
366
  const message = {
@@ -402,22 +385,7 @@ export class IntercomClient extends EventEmitter {
402
385
  return Promise.reject(new Error("Invalid message format"));
403
386
  }
404
387
  return new Promise((resolve, reject) => {
405
- const wrappedResolve = (result) => {
406
- clearTimeout(timeout);
407
- resolve(result);
408
- };
409
- const wrappedReject = (error) => {
410
- clearTimeout(timeout);
411
- reject(error);
412
- };
413
- const timeout = setTimeout(() => {
414
- if (this.pendingSends.has(messageId)) {
415
- this.pendingSends.delete(messageId);
416
- wrappedReject(new Error("Send timeout"));
417
- }
418
- }, this.sendTimeoutMs);
419
- timeout.unref();
420
- this.pendingSends.set(messageId, { resolve: wrappedResolve, reject: wrappedReject });
388
+ this.pendingSends.set(messageId, { resolve, reject }, this.sendTimeoutMs, () => new Error("Send timeout"));
421
389
  try {
422
390
  writeMessage(socket, {
423
391
  type: "send",
@@ -426,9 +394,7 @@ export class IntercomClient extends EventEmitter {
426
394
  });
427
395
  }
428
396
  catch (error) {
429
- clearTimeout(timeout);
430
- this.pendingSends.delete(messageId);
431
- reject(toError(error));
397
+ this.pendingSends.take(messageId)?.reject(toError(error));
432
398
  }
433
399
  });
434
400
  }
@@ -0,0 +1,12 @@
1
+ interface Request {
2
+ readonly reject: (error: Readonly<Error>) => void;
3
+ }
4
+ /** Map membership owns settlement; taking a request also releases its deadline. */
5
+ export declare class PendingRequests<T extends Request> {
6
+ private readonly pending;
7
+ get(id: string): T | undefined;
8
+ set(id: string, value: T, timeoutMs: number, timeoutError: () => Error): void;
9
+ take(id: string): T | undefined;
10
+ fail(error: Readonly<Error>): void;
11
+ }
12
+ export {};
@@ -0,0 +1,26 @@
1
+ /** Map membership owns settlement; taking a request also releases its deadline. */
2
+ export class PendingRequests {
3
+ pending = new Map();
4
+ get(id) {
5
+ return this.pending.get(id)?.value;
6
+ }
7
+ set(id, value, timeoutMs, timeoutError) {
8
+ const timer = setTimeout(() => this.take(id)?.reject(timeoutError()), timeoutMs);
9
+ timer.unref();
10
+ this.pending.set(id, { value, timer });
11
+ }
12
+ take(id) {
13
+ const pending = this.pending.get(id);
14
+ if (pending === undefined) {
15
+ return;
16
+ }
17
+ this.pending.delete(id);
18
+ clearTimeout(pending.timer);
19
+ return pending.value;
20
+ }
21
+ fail(error) {
22
+ for (const id of this.pending.keys()) {
23
+ this.take(id)?.reject(error);
24
+ }
25
+ }
26
+ }