@fitchmultz/pi-subagents 0.52.19 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## 0.52.20 - 2026-11-07
6
+
7
+ <!-- npm-release:unreleased:start -->
8
+ <!-- npm-release:unreleased:end -->
9
+
10
+ - refactor: centralize request settlement and snapshot phases (#354) ([335c7e2](https://github.com/fitchmultz/pi-subagents/commit/335c7e269936467672f7e8d70cc67bf875a0a1c6))
11
+
12
+ ### What's Changed
13
+
14
+ - refactor: centralize broker request settlement by @fitchmultz in https://github.com/fitchmultz/pi-subagents/pull/354
15
+ - refactor: make bridge scheduler ownership explicit by @fitchmultz in https://github.com/fitchmultz/pi-subagents/pull/355
16
+
17
+ **Full Changelog**: https://github.com/fitchmultz/pi-subagents/compare/v0.52.19...v0.52.20
18
+
5
19
  ## 0.52.19 - 2026-11-07
6
20
 
7
21
  <!-- npm-release:unreleased:start -->
@@ -12,8 +26,10 @@
12
26
  - Bind parent attachment to explicit detached, waiting, attaching and attached phases; managed readiness attaches only its own still-current waiting binding and shutdown joins admitted publication before releasing the matching generation.
13
27
  - Add optional `delegate.thinking` to select child effort independently of the parent on all supported hosts, including official Pi 1.5.0. Omission keeps inherited effort. Native model suffixes must agree with an explicit field. Pi still clamps effort to model capabilities; guidance and saved continuations retain the child's saved effort.
14
28
  - Represent child accounting as unrecorded, verified, pending or unavailable while keeping execution status and saved result formats unchanged.
29
+ - Give bridge wake and presence schedulers explicit timer, running, and stopped ownership while retaining durable wake policy.
15
30
  - Make directory-watch availability and the coalesced wake's single-owner contract explicit without changing polling fallback.
16
31
  - Preserve stranded last-good builds until a complete replacement publishes, and report retained recovery paths when a later build fails.
32
+ - Centralize broker request settlement and close multipart snapshot readers after completion or failure.
17
33
  - Give prepared topic transactions action-specific payloads while preserving validate-before-commit and stale-restore normalization.
18
34
 
19
35
  <!-- npm-release:unreleased:end -->
@@ -1 +1 @@
1
- export const EXTENSION_BUILD = Object.freeze({"version":"0.52.19","sha256":"a5cb823f7d9194659bb3c3e53c68e90032ef520a962758d896dccee78a3bd2c5"});
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
  }
@@ -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
+ }
@@ -1,8 +1,9 @@
1
1
  import { type SessionSnapshot } from "../types.ts";
2
2
  /** Multipart frames build a private cache; only a complete validated snapshot escapes. */
3
3
  export declare class SnapshotReader {
4
- private readonly sessions;
5
- private readonly receipts;
4
+ private phase;
5
+ private open;
6
+ fail(): void;
6
7
  private addSession;
7
8
  append(sessions: readonly unknown[], receipts: unknown): void;
8
9
  finish(): SessionSnapshot;
@@ -48,15 +48,25 @@ function appendRows(previous, rows, supplied) {
48
48
  }
49
49
  /** Multipart frames build a private cache; only a complete validated snapshot escapes. */
50
50
  export class SnapshotReader {
51
- sessions = new Map();
52
- receipts = [];
53
- addSession(row) {
51
+ phase = { kind: "open", sessions: new Map(), receipts: [] };
52
+ open() {
53
+ if (this.phase.kind !== "open") {
54
+ throw new Error(`Session snapshot is ${this.phase.kind}`);
55
+ }
56
+ return this.phase;
57
+ }
58
+ fail() {
59
+ if (this.phase.kind === "open") {
60
+ this.phase = { kind: "failed" };
61
+ }
62
+ }
63
+ addSession(state, row) {
54
64
  if (!isRecord(row) || typeof row.id !== "string") {
55
65
  throw new Error("Invalid session snapshot row");
56
66
  }
57
67
  const topics = optionalArray(row.topics);
58
68
  const subscriptions = optionalArray(row.subscriptions);
59
- const previous = this.sessions.get(row.id);
69
+ const previous = state.sessions.get(row.id);
60
70
  const info = normalizeSessionInfo({
61
71
  ...previous?.info,
62
72
  ...row,
@@ -67,33 +77,42 @@ export class SnapshotReader {
67
77
  throw new Error("Invalid session snapshot");
68
78
  }
69
79
  const { topics: newTopics, subscriptions: newSubscriptions, ...identity } = info;
70
- this.sessions.set(info.id, {
80
+ state.sessions.set(info.id, {
71
81
  info: identity,
72
82
  topics: appendRows(previous?.topics, newTopics, "topics" in row),
73
83
  subscriptions: appendRows(previous?.subscriptions, newSubscriptions, "subscriptions" in row),
74
84
  });
75
85
  }
76
86
  append(sessions, receipts) {
77
- for (const row of sessions) {
78
- this.addSession(row);
79
- }
80
- if (receipts === undefined) {
81
- return;
87
+ const state = this.open();
88
+ try {
89
+ for (const row of sessions) {
90
+ this.addSession(state, row);
91
+ }
92
+ if (receipts === undefined) {
93
+ return;
94
+ }
95
+ if (!isUnknownArray(receipts)) {
96
+ throw new Error("Invalid topic delivery receipts");
97
+ }
98
+ state.receipts.push(...receipts.map(receipt));
82
99
  }
83
- if (!isUnknownArray(receipts)) {
84
- throw new Error("Invalid topic delivery receipts");
100
+ catch (error) {
101
+ this.fail();
102
+ throw error;
85
103
  }
86
- this.receipts.push(...receipts.map(receipt));
87
104
  }
88
105
  finish() {
106
+ const state = this.open();
89
107
  const sessions = [];
90
- for (const { info, topics, subscriptions } of this.sessions.values()) {
108
+ for (const { info, topics, subscriptions } of state.sessions.values()) {
91
109
  sessions.push({
92
110
  ...info,
93
111
  ...(topics !== undefined ? { topics } : {}),
94
112
  ...(subscriptions !== undefined ? { subscriptions } : {}),
95
113
  });
96
114
  }
97
- return { sessions, receipts: this.receipts };
115
+ this.phase = { kind: "finished" };
116
+ return { sessions, receipts: state.receipts };
98
117
  }
99
118
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitchmultz/pi-subagents",
3
- "version": "0.52.19",
3
+ "version": "0.52.20",
4
4
  "description": "Pi extension for subagent orchestration and local session coordination",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -18,11 +18,15 @@ interface PresenceOwner {
18
18
  readonly restored: (identity: Identity, expires: number) => void;
19
19
  }
20
20
 
21
+ type PresencePhase =
22
+ | { readonly kind: "idle" }
23
+ | { readonly kind: "scheduled"; readonly timer: NodeJS.Timeout }
24
+ | { readonly kind: "running"; readonly task: Promise<void> }
25
+ | { readonly kind: "stopped"; readonly task: Promise<void> };
26
+
21
27
  export class PersistentPresence {
22
28
  private readonly registrations = new Map<string, Registration>();
23
- private stopped = false;
24
- private timer?: NodeJS.Timeout;
25
- private running?: Promise<void> | undefined;
29
+ private phase: PresencePhase = { kind: "idle" };
26
30
  private readonly owner: PresenceOwner;
27
31
 
28
32
  constructor(owner: PresenceOwner) {
@@ -88,22 +92,30 @@ export class PersistentPresence {
88
92
  }
89
93
 
90
94
  start(): void {
91
- if (!this.stopped && !this.running) {
92
- clearTimeout(this.timer);
93
- this.timer = setTimeout(() => {
94
- this.running = this.maintain().finally(() => {
95
- this.running = undefined;
96
- if (!this.stopped) {
97
- this.timer = setTimeout(() => this.start(), 15000);
98
- }
99
- });
100
- }, 0);
95
+ if (this.phase.kind === "running" || this.phase.kind === "stopped") {
96
+ return;
97
+ }
98
+ this.schedule(0);
99
+ }
100
+
101
+ private schedule(delay: number): void {
102
+ if (this.phase.kind === "scheduled") {
103
+ clearTimeout(this.phase.timer);
101
104
  }
105
+ const timer = setTimeout(() => {
106
+ const task = this.maintain().finally(() => {
107
+ if (this.phase.kind !== "stopped") {
108
+ this.schedule(15000);
109
+ }
110
+ });
111
+ this.phase = { kind: "running", task };
112
+ }, delay);
113
+ this.phase = { kind: "scheduled", timer };
102
114
  }
103
115
 
104
116
  private async maintain(): Promise<void> {
105
117
  for (const registration of this.registrations.values()) {
106
- if (this.stopped) {
118
+ if (this.phase.kind === "stopped") {
107
119
  return;
108
120
  }
109
121
  const identity = this.owner.clients().get(registration.fingerprint);
@@ -125,9 +137,16 @@ export class PersistentPresence {
125
137
  }
126
138
  }
127
139
 
128
- async stop(): Promise<void> {
129
- this.stopped = true;
130
- clearTimeout(this.timer);
131
- await this.running;
140
+ stop(): Promise<void> {
141
+ const phase = this.phase;
142
+ if (phase.kind === "stopped") {
143
+ return phase.task;
144
+ }
145
+ if (phase.kind === "scheduled") {
146
+ clearTimeout(phase.timer);
147
+ }
148
+ const task = phase.kind === "running" ? phase.task : Promise.resolve();
149
+ this.phase = { kind: "stopped", task };
150
+ return task;
132
151
  }
133
152
  }
@@ -15,6 +15,12 @@ interface Candidate {
15
15
  readonly entry: PendingNotification;
16
16
  }
17
17
 
18
+ type DeliveryPhase =
19
+ | { readonly kind: "idle" }
20
+ | { readonly kind: "scheduled"; readonly timer: NodeJS.Timeout }
21
+ | { readonly kind: "running" | "running-dirty"; readonly task: Promise<void> }
22
+ | { readonly kind: "stopped"; readonly task: Promise<void> };
23
+
18
24
  function actionable(entry: PendingNotification): boolean {
19
25
  const message = entry.message;
20
26
  if (entry.acknowledged || message.passive === true || message.delivery === "passive") {
@@ -39,9 +45,7 @@ export class WakeDeliveries {
39
45
  private readonly allowed: (identity: Identity) => boolean;
40
46
  private readonly deliveries = new Map<string, Delivery>();
41
47
  private readonly controller = new AbortController();
42
- private timer?: NodeJS.Timeout;
43
- private running?: Promise<void> | undefined;
44
- private dirty = false;
48
+ private phase: DeliveryPhase = { kind: "idle" };
45
49
  private storageBackoff = 15000;
46
50
 
47
51
  constructor(stateDir: string | undefined, allowed: (identity: Identity) => boolean) {
@@ -83,10 +87,17 @@ export class WakeDeliveries {
83
87
  } catch {
84
88
  // The scheduled storage recovery fails closed; never let an event bypass durable admission.
85
89
  }
86
- if (this.running) {
87
- this.dirty = true;
88
- } else {
89
- this.schedule(Date.now());
90
+ const phase = this.phase;
91
+ switch (phase.kind) {
92
+ case "running":
93
+ this.phase = { kind: "running-dirty", task: phase.task };
94
+ return;
95
+ case "running-dirty":
96
+ case "stopped":
97
+ return;
98
+ case "idle":
99
+ case "scheduled":
100
+ this.schedule(Date.now());
90
101
  }
91
102
  }
92
103
 
@@ -101,31 +112,40 @@ export class WakeDeliveries {
101
112
  }
102
113
 
103
114
  private schedule(deadline: number): void {
104
- clearTimeout(this.timer);
105
- this.timer = setTimeout(
106
- () => {
107
- this.dirty = false;
108
- let next = Infinity;
109
- this.running = this.deliver()
110
- .then((due) => {
111
- next = due;
112
- this.storageBackoff = 15000;
113
- return;
114
- })
115
- .catch(() => {
116
- log("wake_delivery", { result: "storage_unavailable" });
117
- next = Date.now() + this.storageBackoff;
118
- this.storageBackoff = Math.min(this.storageBackoff * 2, 300000);
119
- })
120
- .finally(() => {
121
- this.running = undefined;
122
- if (!this.controller.signal.aborted && (this.dirty || Number.isFinite(next))) {
123
- this.schedule(this.dirty ? Date.now() : next);
124
- }
125
- });
126
- },
115
+ if (this.phase.kind === "scheduled") {
116
+ clearTimeout(this.phase.timer);
117
+ }
118
+ const timer = setTimeout(
119
+ () => this.startDelivery(),
127
120
  Math.max(0, Math.min(deadline - Date.now(), 0x7fffffff)),
128
121
  );
122
+ this.phase = { kind: "scheduled", timer };
123
+ }
124
+
125
+ private startDelivery(): void {
126
+ let next = Infinity;
127
+ const task = this.deliver()
128
+ .then((due) => {
129
+ next = due;
130
+ this.storageBackoff = 15000;
131
+ return;
132
+ })
133
+ .catch(() => {
134
+ log("wake_delivery", { result: "storage_unavailable" });
135
+ next = Date.now() + this.storageBackoff;
136
+ this.storageBackoff = Math.min(this.storageBackoff * 2, 300000);
137
+ })
138
+ .finally(() => {
139
+ if (this.phase.kind === "stopped") {
140
+ return;
141
+ }
142
+ const dirty = this.phase.kind === "running-dirty";
143
+ this.phase = { kind: "idle" };
144
+ if (dirty || Number.isFinite(next)) {
145
+ this.schedule(dirty ? Date.now() : next);
146
+ }
147
+ });
148
+ this.phase = { kind: "running", task };
129
149
  }
130
150
 
131
151
  private authorized(delivery: Delivery): boolean {
@@ -267,14 +287,26 @@ export class WakeDeliveries {
267
287
  this.changed(false);
268
288
  }
269
289
 
270
- async stop(): Promise<void> {
271
- this.controller.abort();
272
- clearTimeout(this.timer);
273
- await this.running;
274
- for (const delivery of this.deliveries.values()) {
275
- delivery.mailbox.close();
290
+ stop(): Promise<void> {
291
+ const phase = this.phase;
292
+ if (phase.kind === "stopped") {
293
+ return phase.task;
276
294
  }
277
- this.deliveries.clear();
278
- this.state?.close();
295
+ if (phase.kind === "scheduled") {
296
+ clearTimeout(phase.timer);
297
+ }
298
+ const running =
299
+ phase.kind === "running" || phase.kind === "running-dirty" ? phase.task : undefined;
300
+ const task = Promise.resolve(running).then(() => {
301
+ for (const delivery of this.deliveries.values()) {
302
+ delivery.mailbox.close();
303
+ }
304
+ this.deliveries.clear();
305
+ this.state?.close();
306
+ return;
307
+ });
308
+ this.phase = { kind: "stopped", task };
309
+ this.controller.abort();
310
+ return task;
279
311
  }
280
312
  }
@@ -18,6 +18,7 @@ import {
18
18
  type SessionInfo,
19
19
  } from "../types.ts";
20
20
  import { writeMessage } from "./framing.ts";
21
+ import { PendingRequests } from "./pending-requests.ts";
21
22
 
22
23
  /** A peer's read of this session or, for a main, of its exited `parked` child. */
23
24
  export function lastRequest(msg: UnknownRecord): {
@@ -46,35 +47,25 @@ export class LastMessageRequests {
46
47
  constructor(timeoutMs: number) {
47
48
  this.timeoutMs = timeoutMs;
48
49
  }
49
- private readonly pending = new Map<
50
- string,
51
- {
52
- readonly resolve: (result: NativeReadResult) => void;
53
- readonly reject: (error: Readonly<Error>) => void;
54
- readonly timer: NodeJS.Timeout;
55
- }
56
- >();
50
+ private readonly pending = new PendingRequests<{
51
+ readonly resolve: (result: NativeReadResult) => void;
52
+ readonly reject: (error: Readonly<Error>) => void;
53
+ }>();
57
54
 
58
55
  fail(error: Readonly<Error>): void {
59
56
  this.lastMessages = false;
60
57
  this.receiptReads = false;
61
- for (const pending of this.pending.values()) {
62
- clearTimeout(pending.timer);
63
- pending.reject(error);
64
- }
65
- this.pending.clear();
58
+ this.pending.fail(error);
66
59
  }
67
60
 
68
61
  receive(message: UnknownRecord): void {
69
62
  if (typeof message.requestId !== "string" || !isNativeReadResult(message.result)) {
70
63
  throw new Error("Invalid last-message response");
71
64
  }
72
- const pending = this.pending.get(message.requestId);
65
+ const pending = this.pending.take(message.requestId);
73
66
  if (!pending) {
74
67
  return;
75
68
  }
76
- clearTimeout(pending.timer);
77
- this.pending.delete(message.requestId);
78
69
  pending.resolve(message.result);
79
70
  }
80
71
 
@@ -100,18 +91,16 @@ export class LastMessageRequests {
100
91
  }
101
92
  return new Promise((resolve, reject) => {
102
93
  const requestId = randomUUID();
103
- const timer = setTimeout(() => {
104
- this.pending.delete(requestId);
105
- reject(new Error("Last-message read timeout"));
106
- }, this.timeoutMs);
107
- timer.unref();
108
- this.pending.set(requestId, { resolve, reject, timer });
94
+ this.pending.set(
95
+ requestId,
96
+ { resolve, reject },
97
+ this.timeoutMs,
98
+ () => new Error("Last-message read timeout"),
99
+ );
109
100
  try {
110
101
  writeMessage(socket, { type: "last_request", requestId, to, query });
111
102
  } catch (error) {
112
- clearTimeout(timer);
113
- this.pending.delete(requestId);
114
- reject(new Error(errorMessage(error)));
103
+ this.pending.take(requestId)?.reject(new Error(errorMessage(error)));
115
104
  }
116
105
  });
117
106
  }
@@ -4,6 +4,7 @@ import { randomUUID } from "crypto";
4
4
  import { writeMessage } from "./framing.ts";
5
5
  import { BrokerConnection, connectBrokerSocket } from "./connection.ts";
6
6
  import { SnapshotReader } from "./snapshot-reader.ts";
7
+ import { PendingRequests } from "./pending-requests.ts";
7
8
  import { LastMessageRequests, lastRequest } from "./client-last.ts";
8
9
  import { ParkRequests } from "./client-park.ts";
9
10
  import { WakeMessages } from "./client-wake.ts";
@@ -70,18 +71,15 @@ export class IntercomClient extends EventEmitter {
70
71
  readonly wakes = new WakeMessages(this, () => this.requireActiveSocket());
71
72
  private guardedDeliverySupported = false;
72
73
  private readonly lastRequests: LastMessageRequests;
73
- private readonly pendingSends = new Map<
74
- string,
75
- { readonly resolve: (r: SendResult) => void; readonly reject: (e: Readonly<Error>) => void }
76
- >();
77
- private readonly pendingLists = new Map<
78
- string,
79
- {
80
- readonly resolve: (snapshot: SessionSnapshot) => void;
81
- readonly reject: (e: Readonly<Error>) => void;
82
- readonly reader: SnapshotReader;
83
- }
84
- >();
74
+ private readonly pendingSends = new PendingRequests<{
75
+ readonly resolve: (r: SendResult) => void;
76
+ readonly reject: (e: Readonly<Error>) => void;
77
+ }>();
78
+ private readonly pendingLists = new PendingRequests<{
79
+ readonly resolve: (snapshot: SessionSnapshot) => void;
80
+ readonly reject: (e: Readonly<Error>) => void;
81
+ readonly reader: SnapshotReader;
82
+ }>();
85
83
  private connecting = false;
86
84
  private disconnecting = false;
87
85
  private readonly sendTimeoutMs: number;
@@ -96,14 +94,8 @@ export class IntercomClient extends EventEmitter {
96
94
 
97
95
  private failPending(error: Readonly<Error>): void {
98
96
  this.lastRequests.fail(error);
99
- for (const pending of this.pendingSends.values()) {
100
- pending.reject(error);
101
- }
102
- this.pendingSends.clear();
103
- for (const pending of this.pendingLists.values()) {
104
- pending.reject(error);
105
- }
106
- this.pendingLists.clear();
97
+ this.pendingSends.fail(error);
98
+ this.pendingLists.fail(error);
107
99
  }
108
100
 
109
101
  get sessionId(): string | null {
@@ -301,12 +293,11 @@ export class IntercomClient extends EventEmitter {
301
293
  if (type !== "delivered" && typeof reason !== "string") {
302
294
  throw new Error(`Invalid ${errorMessage(type)} message`);
303
295
  }
304
- const pending = this.pendingSends.get(messageId);
296
+ const pending = this.pendingSends.take(messageId);
305
297
  // Late responses are harmless after the caller has timed out.
306
298
  if (!pending) {
307
299
  return;
308
300
  }
309
- this.pendingSends.delete(messageId);
310
301
  pending.resolve({
311
302
  id: messageId,
312
303
  accepted: type !== "delivery_failed",
@@ -330,14 +321,12 @@ export class IntercomClient extends EventEmitter {
330
321
  return;
331
322
  }
332
323
  if (typeof error === "string") {
333
- this.pendingLists.delete(requestId);
334
- pending.reject(new Error(error));
324
+ this.pendingLists.take(requestId)?.reject(new Error(error));
335
325
  return;
336
326
  }
337
327
  pending.reader.append(sessions, receipts);
338
328
  if (more !== true) {
339
- this.pendingLists.delete(requestId);
340
- pending.resolve(pending.reader.finish());
329
+ this.pendingLists.take(requestId)?.resolve(pending.reader.finish());
341
330
  }
342
331
  }
343
332
 
@@ -396,8 +385,8 @@ export class IntercomClient extends EventEmitter {
396
385
 
397
386
  return new Promise((resolve, reject) => {
398
387
  const requestId = randomUUID();
388
+ const reader = new SnapshotReader();
399
389
  const wrappedResolve = (snapshot: SessionSnapshot) => {
400
- clearTimeout(timeout);
401
390
  try {
402
391
  // Commit confirmed subscriptions before handling a following message in the same read.
403
392
  onAccepted?.(snapshot);
@@ -407,23 +396,19 @@ export class IntercomClient extends EventEmitter {
407
396
  }
408
397
  };
409
398
  const wrappedReject = (error: Readonly<Error>) => {
410
- clearTimeout(timeout);
399
+ reader.fail();
411
400
  reject(error);
412
401
  };
413
- const timeout = setTimeout(() => {
414
- if (this.pendingLists.has(requestId)) {
415
- this.pendingLists.delete(requestId);
416
- wrappedReject(
417
- Object.assign(new Error("List sessions timeout"), { code: "list_timeout" }),
418
- );
419
- }
420
- }, this.listTimeoutMs);
421
- timeout.unref();
422
- this.pendingLists.set(requestId, {
423
- resolve: wrappedResolve,
424
- reject: wrappedReject,
425
- reader: new SnapshotReader(),
426
- });
402
+ this.pendingLists.set(
403
+ requestId,
404
+ {
405
+ resolve: wrappedResolve,
406
+ reject: wrappedReject,
407
+ reader,
408
+ },
409
+ this.listTimeoutMs,
410
+ () => Object.assign(new Error("List sessions timeout"), { code: "list_timeout" }),
411
+ );
427
412
  try {
428
413
  writeMessage(socket, {
429
414
  type: "list",
@@ -432,9 +417,7 @@ export class IntercomClient extends EventEmitter {
432
417
  ...(change ? { change } : {}),
433
418
  });
434
419
  } catch (error) {
435
- clearTimeout(timeout);
436
- this.pendingLists.delete(requestId);
437
- reject(toError(error));
420
+ this.pendingLists.take(requestId)?.reject(toError(error));
438
421
  }
439
422
  });
440
423
  }
@@ -456,7 +439,7 @@ export class IntercomClient extends EventEmitter {
456
439
  }
457
440
 
458
441
  const messageId = options.messageId ?? (options.guard ? "" : randomUUID());
459
- if (this.pendingSends.has(messageId)) {
442
+ if (this.pendingSends.get(messageId)) {
460
443
  return Promise.reject(new Error("Message ID already has a pending send"));
461
444
  }
462
445
  const message: Message = {
@@ -482,22 +465,12 @@ export class IntercomClient extends EventEmitter {
482
465
  return Promise.reject(new Error("Invalid message format"));
483
466
  }
484
467
  return new Promise((resolve, reject) => {
485
- const wrappedResolve = (result: SendResult) => {
486
- clearTimeout(timeout);
487
- resolve(result);
488
- };
489
- const wrappedReject = (error: Readonly<Error>) => {
490
- clearTimeout(timeout);
491
- reject(error);
492
- };
493
- const timeout = setTimeout(() => {
494
- if (this.pendingSends.has(messageId)) {
495
- this.pendingSends.delete(messageId);
496
- wrappedReject(new Error("Send timeout"));
497
- }
498
- }, this.sendTimeoutMs);
499
- timeout.unref();
500
- this.pendingSends.set(messageId, { resolve: wrappedResolve, reject: wrappedReject });
468
+ this.pendingSends.set(
469
+ messageId,
470
+ { resolve, reject },
471
+ this.sendTimeoutMs,
472
+ () => new Error("Send timeout"),
473
+ );
501
474
 
502
475
  try {
503
476
  writeMessage(socket, {
@@ -506,9 +479,7 @@ export class IntercomClient extends EventEmitter {
506
479
  message: this._topicsSupported ? compactTopicMessage(message) : message,
507
480
  });
508
481
  } catch (error) {
509
- clearTimeout(timeout);
510
- this.pendingSends.delete(messageId);
511
- reject(toError(error));
482
+ this.pendingSends.take(messageId)?.reject(toError(error));
512
483
  }
513
484
  });
514
485
  }
@@ -0,0 +1,37 @@
1
+ interface Request {
2
+ readonly reject: (error: Readonly<Error>) => void;
3
+ }
4
+
5
+ /** Map membership owns settlement; taking a request also releases its deadline. */
6
+ export class PendingRequests<T extends Request> {
7
+ private readonly pending = new Map<
8
+ string,
9
+ { readonly value: T; readonly timer: NodeJS.Timeout }
10
+ >();
11
+
12
+ get(id: string): T | undefined {
13
+ return this.pending.get(id)?.value;
14
+ }
15
+
16
+ set(id: string, value: T, timeoutMs: number, timeoutError: () => Error): void {
17
+ const timer = setTimeout(() => this.take(id)?.reject(timeoutError()), timeoutMs);
18
+ timer.unref();
19
+ this.pending.set(id, { value, timer });
20
+ }
21
+
22
+ take(id: string): T | undefined {
23
+ const pending = this.pending.get(id);
24
+ if (pending === undefined) {
25
+ return;
26
+ }
27
+ this.pending.delete(id);
28
+ clearTimeout(pending.timer);
29
+ return pending.value;
30
+ }
31
+
32
+ fail(error: Readonly<Error>): void {
33
+ for (const id of this.pending.keys()) {
34
+ this.take(id)?.reject(error);
35
+ }
36
+ }
37
+ }
@@ -58,6 +58,14 @@ interface SessionParts {
58
58
  readonly subscriptions: TopicSubscription[] | undefined;
59
59
  }
60
60
 
61
+ type SnapshotPhase =
62
+ | {
63
+ readonly kind: "open";
64
+ readonly sessions: Map<string, SessionParts>;
65
+ readonly receipts: Receipt[];
66
+ }
67
+ | { readonly kind: "finished" | "failed" };
68
+
61
69
  function appendRows<T>(
62
70
  previous: T[] | undefined,
63
71
  rows: readonly T[] | undefined,
@@ -75,16 +83,28 @@ function appendRows<T>(
75
83
 
76
84
  /** Multipart frames build a private cache; only a complete validated snapshot escapes. */
77
85
  export class SnapshotReader {
78
- private readonly sessions = new Map<string, SessionParts>();
79
- private readonly receipts: Receipt[] = [];
86
+ private phase: SnapshotPhase = { kind: "open", sessions: new Map(), receipts: [] };
87
+
88
+ private open(): Extract<SnapshotPhase, { readonly kind: "open" }> {
89
+ if (this.phase.kind !== "open") {
90
+ throw new Error(`Session snapshot is ${this.phase.kind}`);
91
+ }
92
+ return this.phase;
93
+ }
80
94
 
81
- private addSession(row: unknown): void {
95
+ fail(): void {
96
+ if (this.phase.kind === "open") {
97
+ this.phase = { kind: "failed" };
98
+ }
99
+ }
100
+
101
+ private addSession(state: Extract<SnapshotPhase, { readonly kind: "open" }>, row: unknown): void {
82
102
  if (!isRecord(row) || typeof row.id !== "string") {
83
103
  throw new Error("Invalid session snapshot row");
84
104
  }
85
105
  const topics = optionalArray(row.topics);
86
106
  const subscriptions = optionalArray(row.subscriptions);
87
- const previous = this.sessions.get(row.id);
107
+ const previous = state.sessions.get(row.id);
88
108
  const info = normalizeSessionInfo({
89
109
  ...previous?.info,
90
110
  ...row,
@@ -95,7 +115,7 @@ export class SnapshotReader {
95
115
  throw new Error("Invalid session snapshot");
96
116
  }
97
117
  const { topics: newTopics, subscriptions: newSubscriptions, ...identity } = info;
98
- this.sessions.set(info.id, {
118
+ state.sessions.set(info.id, {
99
119
  info: identity,
100
120
  topics: appendRows(previous?.topics, newTopics, "topics" in row),
101
121
  subscriptions: appendRows(previous?.subscriptions, newSubscriptions, "subscriptions" in row),
@@ -103,27 +123,35 @@ export class SnapshotReader {
103
123
  }
104
124
 
105
125
  append(sessions: readonly unknown[], receipts: unknown): void {
106
- for (const row of sessions) {
107
- this.addSession(row);
108
- }
109
- if (receipts === undefined) {
110
- return;
111
- }
112
- if (!isUnknownArray(receipts)) {
113
- throw new Error("Invalid topic delivery receipts");
126
+ const state = this.open();
127
+ try {
128
+ for (const row of sessions) {
129
+ this.addSession(state, row);
130
+ }
131
+ if (receipts === undefined) {
132
+ return;
133
+ }
134
+ if (!isUnknownArray(receipts)) {
135
+ throw new Error("Invalid topic delivery receipts");
136
+ }
137
+ state.receipts.push(...receipts.map(receipt));
138
+ } catch (error) {
139
+ this.fail();
140
+ throw error;
114
141
  }
115
- this.receipts.push(...receipts.map(receipt));
116
142
  }
117
143
 
118
144
  finish(): SessionSnapshot {
145
+ const state = this.open();
119
146
  const sessions: SessionInfo[] = [];
120
- for (const { info, topics, subscriptions } of this.sessions.values()) {
147
+ for (const { info, topics, subscriptions } of state.sessions.values()) {
121
148
  sessions.push({
122
149
  ...info,
123
150
  ...(topics !== undefined ? { topics } : {}),
124
151
  ...(subscriptions !== undefined ? { subscriptions } : {}),
125
152
  });
126
153
  }
127
- return { sessions, receipts: this.receipts };
154
+ this.phase = { kind: "finished" };
155
+ return { sessions, receipts: state.receipts };
128
156
  }
129
157
  }