@fitchmultz/pi-subagents 0.51.0 → 0.52.1

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 +18 -0
  2. package/dist/agent/child-runtime.js +80 -0
  3. package/dist/agent/contracts.js +9 -0
  4. package/dist/agent/index.js +6 -20
  5. package/dist/agent/observation.js +4 -0
  6. package/dist/agent/parking.js +253 -0
  7. package/dist/agent/process.js +31 -4
  8. package/dist/agent/runner.js +124 -126
  9. package/dist/agent/shell.js +2 -2
  10. package/dist/agent/storage.js +18 -0
  11. package/dist/build-info.js +1 -1
  12. package/dist/pi-intercom/broker/broker.js +42 -29
  13. package/dist/pi-intercom/broker/client-last.d.ts +12 -1
  14. package/dist/pi-intercom/broker/client-last.js +25 -0
  15. package/dist/pi-intercom/broker/client-park.d.ts +9 -0
  16. package/dist/pi-intercom/broker/client-park.js +21 -0
  17. package/dist/pi-intercom/broker/client.d.ts +2 -3
  18. package/dist/pi-intercom/broker/client.js +23 -28
  19. package/dist/pi-intercom/broker/last.d.ts +5 -1
  20. package/dist/pi-intercom/broker/last.js +34 -12
  21. package/dist/pi-intercom/broker/parked.js +107 -0
  22. package/dist/pi-intercom/broker/server-connection.js +1 -1
  23. package/dist/pi-intercom/connection.js +65 -22
  24. package/dist/pi-intercom/inbound.js +11 -0
  25. package/dist/pi-intercom/index.js +38 -0
  26. package/dist/shared/parking.js +12 -0
  27. package/dist/shared/post-exit-stdio-guard.js +4 -2
  28. package/docs/code-quality.md +5 -5
  29. package/docs/development.md +3 -3
  30. package/docs/intercom.md +2 -1
  31. package/docs/performance.md +31 -3
  32. package/docs/reference.md +12 -1
  33. package/package.json +1 -1
  34. package/scripts/measure-size.mjs +1 -1
  35. package/src/agent/child-runtime.ts +105 -0
  36. package/src/agent/contracts.ts +10 -0
  37. package/src/agent/host.ts +22 -2
  38. package/src/agent/index.ts +10 -17
  39. package/src/agent/observation.ts +4 -0
  40. package/src/agent/parking.ts +341 -0
  41. package/src/agent/process.ts +41 -4
  42. package/src/agent/runner.ts +124 -144
  43. package/src/agent/shell.ts +6 -2
  44. package/src/agent/storage.ts +17 -0
  45. package/src/pi-intercom/broker/broker.ts +44 -37
  46. package/src/pi-intercom/broker/client-last.ts +41 -1
  47. package/src/pi-intercom/broker/client-park.ts +23 -0
  48. package/src/pi-intercom/broker/client.ts +24 -28
  49. package/src/pi-intercom/broker/last.ts +49 -14
  50. package/src/pi-intercom/broker/parked.ts +130 -0
  51. package/src/pi-intercom/broker/server-connection.ts +2 -2
  52. package/src/pi-intercom/connection.ts +87 -34
  53. package/src/pi-intercom/inbound.ts +14 -0
  54. package/src/pi-intercom/index.ts +41 -0
  55. package/src/pi-intercom/last-message.ts +1 -1
  56. package/src/shared/parking.ts +22 -0
  57. package/src/shared/post-exit-stdio-guard.ts +5 -2
@@ -13,7 +13,13 @@ import {
13
13
  } from "../shared/post-exit-stdio-guard.ts";
14
14
  import { agentObservationSocketPath } from "../pi-intercom/broker/paths.ts";
15
15
  import { hasErrorCode } from "../shared/unknown.ts";
16
- import { attachmentSchema, processSchema, type Launch, type ProcessIdentity } from "./contracts.ts";
16
+ import {
17
+ attachmentSchema,
18
+ parkedSchema,
19
+ processSchema,
20
+ type Launch,
21
+ type ProcessIdentity,
22
+ } from "./contracts.ts";
17
23
  import { conversation, enqueue, launchAt, list, read, write } from "./storage.ts";
18
24
  import { errorText } from "./host.ts";
19
25
 
@@ -183,9 +189,12 @@ async function awaitStopping(
183
189
  existing: OwnedProcess | undefined,
184
190
  signal?: AbortSignal,
185
191
  ): Promise<void> {
186
- if (existing && fs.existsSync(path.join(dir, `stopping-${existing.token}.json`))) {
187
- const deadline = Date.now() + 5000;
188
- while (alive(existing)) {
192
+ if (existing) {
193
+ const stopping = path.join(dir, `stopping-${existing.token}.json`);
194
+ // Parking ends in exit or a removed marker, after native shutdown persistence.
195
+ const parking = path.join(dir, "actor.lock", "parking.json");
196
+ const deadline = Date.now() + (fs.existsSync(stopping) ? 5000 : 30_000);
197
+ while ((fs.existsSync(stopping) || fs.existsSync(parking)) && alive(existing)) {
189
198
  signal?.throwIfAborted();
190
199
  if (Date.now() > deadline) {
191
200
  throw new Error("Previous actor is still stopping; no replacement was launched.");
@@ -210,6 +219,7 @@ export async function ensureActor(
210
219
  if (existing && alive(existing)) {
211
220
  return;
212
221
  }
222
+ parkFailure(base, launch, existing);
213
223
  if (existing) {
214
224
  if (shells(base, launch, existing.token).some(alive)) {
215
225
  throw new Error("Owned native shell work remains live; stop it before continuing.");
@@ -231,6 +241,33 @@ export async function ensureActor(
231
241
  write(path.join(lock, "claim.json"), { ...ownIdentity(), token });
232
242
  spawnActor(base, launch, token);
233
243
  }
244
+ /** A failed park keeps its input queued; exact state is lost, so nothing restarts it implicitly. */
245
+ export function parkFailure(base: string, launch: Launch, exited?: OwnedProcess): void {
246
+ const dir = conversation(base, launch);
247
+ const parked = read(path.join(dir, "parked.json"), parkedSchema);
248
+ if (exited && parked && parked.token !== exited.token) {
249
+ // Only the exited claim that saved this state may hand it to the next generation.
250
+ throw new Error(
251
+ "Parked working state belongs to another actor generation; nothing was reopened.",
252
+ );
253
+ }
254
+ const file = path.join(dir, "park-failed.json");
255
+ if (fs.existsSync(file)) {
256
+ throw new Error(
257
+ `This idle child exited without saving its exact working state (${file}). Nothing was restarted or admitted; messages for it stay queued. Inspect it, then delegate new work.`,
258
+ );
259
+ }
260
+ }
261
+ /** A broker wake may only restore exact parked state, never reopen the journal alone. */
262
+ export async function wakeActor(base: string, launch: Launch, signal?: AbortSignal): Promise<void> {
263
+ const dir = conversation(base, launch);
264
+ await awaitStopping(dir, actor(base, launch), signal);
265
+ parkFailure(base, launch);
266
+ if (!fs.existsSync(path.join(dir, "parked.json"))) {
267
+ throw new Error("No parked working state; the child was not woken.");
268
+ }
269
+ await ensureActor(base, launch, signal);
270
+ }
234
271
  /** A crashed generation cannot unlink its endpoint; its exact token names the leftover. */
235
272
  function removeObservationEndpoint(token: string): void {
236
273
  try {
@@ -1,20 +1,16 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import type {
4
- AgentSession,
5
- CreateAgentSessionRuntimeFactory,
6
- ExtensionAPI,
7
- ExtensionContext,
8
- } from "@earendil-works/pi-coding-agent";
3
+ import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
4
  import type { AssistantMessage } from "@earendil-works/pi-ai";
10
5
  import { commandSchema, type Command, type Launch, type Result } from "./contracts.ts";
11
6
  import { launchAt, missing, read, runDirectory, write } from "./storage.ts";
12
- import { ownIdentity, ParentCheck, shellCommand, shells, signalProcess } from "./process.ts";
7
+ import { ownIdentity, ParentCheck, shells, signalProcess } from "./process.ts";
13
8
  import { errorText, loadSDK } from "./host.ts";
9
+ import { Parking, type Runtime } from "./parking.ts";
14
10
  import { childUsage } from "./usage.ts";
15
- import { initializeCwd, localCwdOwner } from "./cwd.ts";
11
+ import { initializeCwd } from "./cwd.ts";
12
+ import { childRuntime } from "./child-runtime.ts";
16
13
  import { ActorObservation, type ObservationStats } from "./observation.ts";
17
- import type { ToolEvent } from "./observation-state.ts";
18
14
  import { DirectoryWatch, Wake } from "./wake.ts";
19
15
 
20
16
  function commandIdentity(file: string): string | undefined {
@@ -41,30 +37,12 @@ function completionStatus(
41
37
  }
42
38
  return "completed";
43
39
  }
44
- function guard(pi: ExtensionAPI, directory: string, token: string): void {
45
- pi.on("tool_call", (event) => {
46
- if (event.toolName === "delegate" || event.toolName === "agent_runs") {
47
- return { block: true, reason: "Only a main session may delegate or control children." };
48
- }
49
- const owner = pi.getAllTools().find((tool) => tool.name === "bash")?.sourceInfo;
50
- if (
51
- event.toolName === "bash" &&
52
- typeof event.input.command === "string" &&
53
- owner &&
54
- (owner.source === "builtin" || localCwdOwner(owner))
55
- ) {
56
- // Preserve the selected local owner's cwd/env and leave remote/custom tools alone.
57
- const input = event.input;
58
- input.command = shellCommand(event.input.command, directory, token);
59
- }
60
- return;
61
- });
62
- }
63
40
  class Actor {
64
41
  private active?: Promise<void>;
65
42
  private stopping = false;
66
43
  private stopDeadline?: NodeJS.Timeout;
67
44
  private nextParentCheck = 0;
45
+ private parking = false;
68
46
  private readonly parent = new ParentCheck();
69
47
  // Raised when durable commands may need admission: change hints and stop.
70
48
  private readonly wake = new Wake();
@@ -151,67 +129,6 @@ class Actor {
151
129
  .join("\n") ?? ""
152
130
  );
153
131
  }
154
- private createRuntime(
155
- sdk: Awaited<ReturnType<typeof loadSDK>>,
156
- ): CreateAgentSessionRuntimeFactory {
157
- return async ({ sessionManager }) => {
158
- const launch = this.initial;
159
- if (!fs.existsSync(launch.sessionFile)) {
160
- throw new Error("Missing saved native session; refusing to create another.");
161
- }
162
- const settingsManager = sdk.SettingsManager.create(launch.cwd, launch.agentDir, {
163
- projectTrusted: launch.trusted,
164
- });
165
- const services = await sdk.createAgentSessionServices({
166
- cwd: launch.cwd,
167
- agentDir: launch.agentDir,
168
- settingsManager,
169
- resourceLoaderOptions: {
170
- additionalExtensionPaths: [launch.entry],
171
- extensionFactories: [
172
- {
173
- name: "codemode",
174
- builtin: true,
175
- factory: sdk.createCodemodeExtension({ mode: "on" }),
176
- },
177
- { name: "tool_search", builtin: true, factory: sdk.createToolSearchExtension() },
178
- { name: "mcp", builtin: true, factory: sdk.createMcpExtension() },
179
- (pi) => {
180
- guard(pi, this.dir, this.token);
181
- pi.on("session_start", (_event, ctx) => {
182
- this.context = { pi, ctx };
183
- });
184
- // Sparse tool lifecycle keeps the live view's tools even while nobody watches.
185
- const tool = (event: ToolEvent): void => this.observation?.tool(event);
186
- pi.on("tool_execution_start", tool);
187
- pi.on("tool_execution_update", tool);
188
- pi.on("tool_execution_end", tool);
189
- },
190
- ],
191
- },
192
- });
193
- const diagnostics = services.resourceLoader.getExtensions().errors;
194
- if (diagnostics.length > 0) {
195
- throw new Error(`Child extension load failed: ${JSON.stringify(diagnostics)}`);
196
- }
197
- const model = services.modelRuntime.getModel(launch.provider, launch.model);
198
- if (!model) {
199
- throw new Error(
200
- `Saved model ${launch.provider}/${launch.model} is unavailable; no fallback was used.`,
201
- );
202
- }
203
- return {
204
- ...(await sdk.createAgentSessionFromServices({
205
- services,
206
- sessionManager,
207
- model,
208
- thinkingLevel: launch.thinking,
209
- })),
210
- services,
211
- diagnostics: services.diagnostics,
212
- };
213
- };
214
- }
215
132
  private readonly stop = (): void => {
216
133
  if (this.stopping) {
217
134
  return;
@@ -399,7 +316,7 @@ class Actor {
399
316
  return this.stopping && this.active === undefined && (this.session?.isIdle ?? true);
400
317
  }
401
318
  /** Durable command readiness, not elapsed time, defines admission. */
402
- private async admitCommands(): Promise<void> {
319
+ private async admitCommands(runtime: Runtime, parked: Parking): Promise<void> {
403
320
  // Queued commands create this directory; otherwise pending() does and the next pass arms.
404
321
  const directory = path.join(this.dir, "commands");
405
322
  const commands = new DirectoryWatch(directory, () => true, this.wake.raise);
@@ -411,6 +328,10 @@ class Actor {
411
328
  this.retry = false;
412
329
  this.pending().forEach((command) => this.accept(command));
413
330
  }
331
+ // oxlint-disable-next-line no-await-in-loop
332
+ if (await this.park(runtime, parked)) {
333
+ return;
334
+ }
414
335
  const polling = this.retry || this.stopping || watch === "unwatched";
415
336
  const tick = Math.max(1, this.nextParentCheck - performance.now());
416
337
  // oxlint-disable-next-line no-await-in-loop
@@ -422,6 +343,35 @@ class Actor {
422
343
  commands.close();
423
344
  }
424
345
  }
346
+ /** Parks a final-idle actor; the parent/stop owner stays live while preparation awaits. */
347
+ private async park(runtime: Runtime, parked: Parking): Promise<boolean> {
348
+ const owner = new AbortController();
349
+ const watch = setInterval(() => {
350
+ if (this.shouldExit() || this.stopping) {
351
+ owner.abort();
352
+ }
353
+ }, 100);
354
+ try {
355
+ this.parking = await parked.park(runtime, {
356
+ signal: owner.signal,
357
+ pi: this.context?.pi,
358
+ busy: this.stopping || this.active !== undefined || this.observation?.watched === true,
359
+ // Raced commands and viewers keep this actor resident; the loop admits after release.
360
+ quiet: () => {
361
+ const queued = this.pending().some((command) => this.commandLaunch(command));
362
+ if (queued) {
363
+ this.wake.raise();
364
+ }
365
+ const idle = this.active === undefined && !this.stopping;
366
+ return !queued && idle && this.observation?.watched !== true;
367
+ },
368
+ errors: this.errors,
369
+ });
370
+ } finally {
371
+ clearInterval(watch);
372
+ }
373
+ return this.parking;
374
+ }
425
375
  private failure(error: unknown): void {
426
376
  const launch = this.current ?? this.initial;
427
377
  const result: Result = {
@@ -438,71 +388,101 @@ class Actor {
438
388
  }
439
389
  process.exitCode = 1;
440
390
  }
391
+ private async start(sdk: Awaited<ReturnType<typeof loadSDK>>, parked: Parking): Promise<Runtime> {
392
+ const sessionManager = parked.open();
393
+ const factory = childRuntime(sdk, this.initial, {
394
+ directory: this.dir,
395
+ token: this.token,
396
+ saved: parked.saved,
397
+ started: (pi, ctx) => {
398
+ this.context = { pi, ctx };
399
+ },
400
+ tool: (event) => this.observation?.tool(event),
401
+ });
402
+ const runtime = await sdk.createAgentSessionRuntime(factory, {
403
+ cwd: this.initial.cwd,
404
+ agentDir: this.initial.agentDir,
405
+ sessionManager,
406
+ });
407
+ try {
408
+ await this.ready(runtime, parked);
409
+ } catch (error) {
410
+ // A runtime that never became ready is still disposed, so the failed actor exits.
411
+ await runtime.dispose();
412
+ throw error;
413
+ }
414
+ return runtime;
415
+ }
416
+ private async ready(runtime: Runtime, parked: Parking): Promise<void> {
417
+ this.session = runtime.session;
418
+ await this.session.bindExtensions({
419
+ mode: "print",
420
+ onError: (error) => {
421
+ this.errors.push(error.error);
422
+ },
423
+ });
424
+ if (!this.context) {
425
+ throw new Error("Child extension context is unavailable.");
426
+ }
427
+ initializeCwd(this.context.pi, this.context.ctx, this.initial.cwd, this.dir);
428
+ if (this.initial.name !== undefined && !parked.saved) {
429
+ this.session.sessionManager.appendSessionInfo(this.initial.name);
430
+ }
431
+ // Optional live view, bound before readiness; its faults never change native execution.
432
+ this.observation = await ActorObservation.open(this.session, {
433
+ token: this.token,
434
+ rootId: this.initial.rootId,
435
+ sessionId: this.session.sessionManager.getSessionId(),
436
+ });
437
+ write(path.join(this.dir, "actor.lock", "process.json"), {
438
+ ...ownIdentity(),
439
+ token: this.token,
440
+ });
441
+ write(path.join(this.dir, "ready.json"), {
442
+ pid: process.pid,
443
+ token: this.token,
444
+ sessionId: this.session.sessionManager.getSessionId(),
445
+ at: Date.now(),
446
+ });
447
+ parked.adopt();
448
+ }
441
449
  async serve(): Promise<void> {
442
450
  const sdk = await loadSDK(this.initial.sdk);
443
- let runtime: Awaited<ReturnType<typeof sdk.createAgentSessionRuntime>> | undefined;
451
+ const parked = new Parking(this.base, this.initial, this.token, sdk);
452
+ let runtime: Runtime | undefined;
444
453
  try {
445
- runtime = await sdk.createAgentSessionRuntime(this.createRuntime(sdk), {
446
- cwd: this.initial.cwd,
447
- agentDir: this.initial.agentDir,
448
- sessionManager: sdk.SessionManager.open(
449
- this.initial.sessionFile,
450
- undefined,
451
- this.initial.cwd,
452
- ),
453
- });
454
- this.session = runtime.session;
455
- await this.session.bindExtensions({
456
- mode: "print",
457
- onError: (error) => {
458
- this.errors.push(error.error);
459
- },
460
- });
461
- if (!this.context) {
462
- throw new Error("Child extension context is unavailable.");
463
- }
464
- initializeCwd(this.context.pi, this.context.ctx, this.initial.cwd, this.dir);
465
- if (this.initial.name !== undefined) {
466
- this.session.sessionManager.appendSessionInfo(this.initial.name);
467
- }
468
- // Optional live view, bound before readiness; its faults never change native execution.
469
- this.observation = await ActorObservation.open(this.session, {
470
- token: this.token,
471
- rootId: this.initial.rootId,
472
- sessionId: this.session.sessionManager.getSessionId(),
473
- });
474
- write(path.join(this.dir, "actor.lock", "process.json"), {
475
- ...ownIdentity(),
476
- token: this.token,
477
- });
478
- write(path.join(this.dir, "ready.json"), {
479
- pid: process.pid,
480
- token: this.token,
481
- sessionId: this.session.sessionManager.getSessionId(),
482
- at: Date.now(),
483
- });
454
+ runtime = await this.start(sdk, parked);
484
455
  process.on("SIGTERM", this.stop);
485
456
  process.on("SIGINT", this.stop);
486
- await this.admitCommands();
457
+ await this.admitCommands(runtime, parked);
487
458
  await this.active;
488
459
  } catch (error) {
489
460
  this.failure(error);
490
461
  } finally {
491
- process.off("SIGTERM", this.stop);
492
- process.off("SIGINT", this.stop);
493
- const observation: ObservationStats | undefined = this.observation?.close();
462
+ await this.finish(runtime);
463
+ }
464
+ }
465
+ private async finish(runtime: Runtime | undefined): Promise<void> {
466
+ process.off("SIGTERM", this.stop);
467
+ process.off("SIGINT", this.stop);
468
+ const observation: ObservationStats | undefined = this.observation?.close();
469
+ if (!this.parking) {
494
470
  await runtime?.dispose();
495
- if (this.stopping) {
496
- this.stopShells("SIGKILL");
497
- }
498
- clearTimeout(this.stopDeadline);
499
- write(path.join(this.dir, `shutdown-${this.token}.json`), {
500
- at: Date.now(),
501
- errors: this.errors,
502
- ...(observation === undefined ? {} : { observation }),
503
- });
504
- // Keep the process claim until a launcher proves this exact actor is gone.
505
471
  }
472
+ if (this.stopping) {
473
+ this.stopShells("SIGKILL");
474
+ }
475
+ clearTimeout(this.stopDeadline);
476
+ write(path.join(this.dir, `shutdown-${this.token}.json`), {
477
+ at: Date.now(),
478
+ errors: this.errors,
479
+ ...(observation === undefined ? {} : { observation }),
480
+ });
481
+ if (this.parking) {
482
+ // Parked authority requires actual exit; no leftover extension handle may keep it alive.
483
+ process.exit();
484
+ }
485
+ // Keep the process claim until a launcher proves this exact actor is gone.
506
486
  }
507
487
  }
508
488
  const base = process.argv.at(2);
@@ -2,7 +2,11 @@ import { randomUUID } from "node:crypto";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import * as path from "node:path";
4
4
  import * as fs from "node:fs";
5
- import { readChildProcessIdentity, readProcessClock } from "../shared/post-exit-stdio-guard.ts";
5
+ import {
6
+ PS_COMMAND,
7
+ readChildProcessIdentity,
8
+ readProcessClock,
9
+ } from "../shared/post-exit-stdio-guard.ts";
6
10
  import { read, write } from "./storage.ts";
7
11
  import { processSchema } from "./contracts.ts";
8
12
  import { verifyProcess } from "./process.ts";
@@ -22,7 +26,7 @@ if (
22
26
  throw new Error("Native shell registration requires its actual parent shell.");
23
27
  }
24
28
  const group = Number(
25
- execFileSync("ps", ["-p", String(pid), "-o", "pgid="], {
29
+ execFileSync(PS_COMMAND, ["-p", String(pid), "-o", "pgid="], {
26
30
  encoding: "utf8",
27
31
  timeout: 1000,
28
32
  stdio: ["ignore", "pipe", "ignore"],
@@ -84,6 +84,23 @@ export function launchAt(base: string, id: string, owner: string): Launch {
84
84
  }
85
85
  return launch;
86
86
  }
87
+ /** Claims the single successor of a saved run; undefined when another caller already has. */
88
+ export function successor(base: string, launch: Launch, prompt: string): Launch | undefined {
89
+ const id = randomUUID();
90
+ const next: Launch = { ...launch, id, predecessor: launch.id, prompt, createdAt: Date.now() };
91
+ fs.mkdirSync(runDirectory(base, id), { mode: 0o700 });
92
+ write(path.join(runDirectory(base, id), "launch.json"), next, true);
93
+ try {
94
+ write(path.join(runDirectory(base, launch.id), "successor.json"), { id }, true);
95
+ } catch (error) {
96
+ fs.rmSync(runDirectory(base, id), { recursive: true });
97
+ if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) {
98
+ throw error;
99
+ }
100
+ return undefined;
101
+ }
102
+ return next;
103
+ }
87
104
  export function latest(base: string, initial: Launch): Launch {
88
105
  const seen = new Set<string>();
89
106
  let launch = initial;
@@ -8,6 +8,7 @@ import { brokerPidRecord } from "./pid.ts";
8
8
  import { assertBrokerCanStart } from "./spawn.ts";
9
9
  import { LastMessageRelay } from "./last.ts";
10
10
  import { canSeeSession, findSessions } from "./targets.ts";
11
+ import { ParkedSessions, reachable, type ParkablePeer } from "./parked.ts";
11
12
  import { acceptBrokerConnection, registeredSessionInfo } from "./server-connection.ts";
12
13
  import { guardedDeliveryError } from "./guard.ts";
13
14
  import {
@@ -35,8 +36,7 @@ import {
35
36
  const PID_PATH = join(getBrokerRuntimeDir(), "broker.pid");
36
37
  const REPLACE_DELIVERY_DELAY_MS = 1500;
37
38
 
38
- interface ConnectedSession {
39
- readonly socket: net.Socket;
39
+ interface ConnectedSession extends ParkablePeer {
40
40
  info: SessionInfo;
41
41
  topics: TopicSnapshot;
42
42
  readonly topicFrames: boolean;
@@ -54,6 +54,7 @@ interface PendingReplaceDelivery {
54
54
  class IntercomBroker {
55
55
  private readonly sessions = new Map<string, ConnectedSession>();
56
56
  private readonly lastMessages = new LastMessageRelay(this.sessions);
57
+ private readonly parking = new ParkedSessions(this.sessions, this.deliverMessage.bind(this));
57
58
  private readonly pendingReplaceDeliveries = new Map<string, PendingReplaceDelivery>();
58
59
  private readonly server: net.Server;
59
60
  private shutdownTimer: NodeJS.Timeout | null = null;
@@ -96,7 +97,7 @@ class IntercomBroker {
96
97
 
97
98
  this.shutdownTimer = setTimeout(() => {
98
99
  this.shutdownTimer = null;
99
- if (this.sessions.size === 0) {
100
+ if (this.parking.exited) {
100
101
  console.log("No sessions connected, shutting down");
101
102
  this.shutdown();
102
103
  }
@@ -130,7 +131,7 @@ class IntercomBroker {
130
131
  }
131
132
  switch (msg.type) {
132
133
  case "unregister":
133
- this.removeSession(currentId);
134
+ this.removeSession(currentId, socket);
134
135
  setId(null);
135
136
  break;
136
137
  case "list":
@@ -140,7 +141,8 @@ class IntercomBroker {
140
141
  this.send(socket, msg, currentId);
141
142
  break;
142
143
  case "last_request":
143
- this.lastMessages.request(currentId, msg);
144
+ // Reads never spawn a parked child.
145
+ this.lastMessages.request(currentId, msg, (id) => this.parking.reader(id));
144
146
  break;
145
147
  case "last_response":
146
148
  this.lastMessages.respond(currentId, msg);
@@ -148,22 +150,29 @@ class IntercomBroker {
148
150
  case "presence":
149
151
  this.presence(msg, currentId);
150
152
  break;
153
+ case "park":
154
+ case "unpark":
155
+ this.parking.park(socket, currentId, msg.type === "park");
156
+ break;
151
157
  default:
152
158
  throw new Error(`Unknown client message type: ${msg.type}`);
153
159
  }
154
160
  }
155
161
 
156
162
  private register(socket: net.Socket, msg: UnknownRecord, setId: (id: string) => void): void {
157
- const info = registeredSessionInfo(msg, this.sessions);
158
- const id = info.id;
163
+ const registered = registeredSessionInfo(msg, this.sessions);
164
+ const { id, resumed } = this.parking.claim(msg.requestedId, registered);
165
+ const info: SessionInfo = { ...registered, id };
159
166
  snapshotFrames(randomUUID(), [info], true);
160
167
  const topicFrames = msg.topicFrames === true;
161
168
  this.sessions.set(id, {
162
169
  socket,
163
170
  info: messageSender(info),
164
- topics: new TopicSnapshot(info),
171
+ // A woken generation registers no topics; the parked identity's owner keeps them.
172
+ topics: resumed?.topics ?? new TopicSnapshot(info),
165
173
  topicFrames,
166
174
  lastMessages: msg.lastMessages === true,
175
+ parking: msg.parking === true,
167
176
  receiptReads: msg.receiptReads === 1,
168
177
  });
169
178
  setId(id);
@@ -175,21 +184,25 @@ class IntercomBroker {
175
184
  type: "registered",
176
185
  sessionId: id,
177
186
  lastMessages: true,
187
+ parking: msg.parking === true,
178
188
  receiptReads: 1,
179
189
  ...(msg.guardedDelivery === 1 ? { guardedDelivery: 1 } : {}),
180
190
  ...(topicFrames ? { topicsSupported: true, topicFrames: true } : {}),
181
191
  });
192
+ this.parking.registered(id, resumed);
182
193
  }
183
194
 
184
- private removeSession(sessionId: string): void {
195
+ private removeSession(sessionId: string, socket: net.Socket): void {
185
196
  const session = this.sessions.get(sessionId);
186
- if (!session) {
197
+ if (session?.socket !== socket) {
187
198
  return;
188
199
  }
189
- this.sessions.delete(sessionId);
190
200
  this.lastMessages.disconnected(sessionId);
191
- this.clearPendingReplaceDeliveries(sessionId);
192
- this.broadcast({ type: "session_left", sessionId }, session.info);
201
+ if (!this.parking.closed(session)) {
202
+ this.sessions.delete(sessionId);
203
+ this.clearPendingReplaceDeliveries(sessionId);
204
+ this.broadcast({ type: "session_left", sessionId }, session.info);
205
+ }
193
206
  this.scheduleShutdownCheck();
194
207
  }
195
208
 
@@ -208,7 +221,7 @@ class IntercomBroker {
208
221
  continue;
209
222
  }
210
223
  const message = publicationMessage(publication, subscription.awaitRelease === true);
211
- const error = this.validateDeliveryPayload(from, message, target.topicFrames);
224
+ const error = this.validateDeliveryPayload(from, message, target);
212
225
  if (error !== null) {
213
226
  throw new Error(error);
214
227
  }
@@ -374,14 +387,16 @@ class IntercomBroker {
374
387
  message: Message,
375
388
  ): SendResult {
376
389
  const target = this.sessions.get(toId);
377
- if (target && shouldReplaceDelivery(message, target.info)) {
390
+ if (target && target.parked === undefined && shouldReplaceDelivery(message, target.info)) {
378
391
  return this.queueReplaceDelivery(fromId, toId, from, message);
379
392
  }
380
393
  const reason = this.deliverMessage(toId, from, message);
394
+ const queued = reason === null && target?.parked !== undefined;
381
395
  return {
382
396
  id: message.id,
383
397
  accepted: reason === null,
384
- delivered: reason === null,
398
+ delivered: reason === null && !queued,
399
+ ...(queued ? { queued } : {}),
385
400
  ...(reason !== null ? { reason } : {}),
386
401
  };
387
402
  }
@@ -410,11 +425,7 @@ class IntercomBroker {
410
425
  from: SessionInfo,
411
426
  message: Message,
412
427
  ): SendResult {
413
- const validationError = this.validateDeliveryPayload(
414
- from,
415
- message,
416
- this.sessions.get(toId)?.topicFrames === true,
417
- );
428
+ const validationError = this.validateDeliveryPayload(from, message, this.sessions.get(toId));
418
429
  if (validationError !== null) {
419
430
  return { id: message.id, accepted: false, delivered: false, reason: validationError };
420
431
  }
@@ -450,37 +461,33 @@ class IntercomBroker {
450
461
  }
451
462
  }
452
463
 
464
+ /** One admission rule; retained input is checked as the uncompacted envelope any later
465
+ * transport generation accepts. */
453
466
  private validateDeliveryPayload(
454
467
  from: SessionInfo,
455
468
  message: Message,
456
- topicFrames: boolean,
469
+ target: ConnectedSession | undefined,
457
470
  ): string | null {
471
+ const compact = target?.parked === undefined && target?.topicFrames === true;
458
472
  return (
459
473
  validateIntercomMessageSize({
460
474
  type: "message",
461
475
  from: messageSender(from),
462
- message: topicFrames ? compactTopicMessage(message) : message,
476
+ message: compact ? compactTopicMessage(message) : message,
463
477
  })?.message ?? null
464
478
  );
465
479
  }
466
480
 
467
481
  private deliverMessage(toId: string, from: SessionInfo, message: Message): string | null {
468
- const guardError = guardedDeliveryError(this.sessions, toId, message);
469
- if (guardError !== null) {
470
- return guardError;
471
- }
472
482
  const target = this.sessions.get(toId);
473
- if (
474
- !target ||
475
- target.socket.destroyed ||
476
- target.socket.writableEnded ||
477
- !target.socket.writable
478
- ) {
479
- return "Recipient disconnected before delivery";
483
+ const refused =
484
+ guardedDeliveryError(this.sessions, toId, message) ??
485
+ this.validateDeliveryPayload(from, message, target);
486
+ if (refused !== null || this.parking.retain(target, from, message)) {
487
+ return refused;
480
488
  }
481
- const validationError = this.validateDeliveryPayload(from, message, target.topicFrames);
482
- if (validationError !== null) {
483
- return validationError;
489
+ if (!reachable(target)) {
490
+ return "Recipient disconnected before delivery";
484
491
  }
485
492
  this.touchActivity(toId, true);
486
493
  try {