@fitchmultz/pi-subagents 0.51.0 → 0.52.0

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 (53) hide show
  1. package/CHANGELOG.md +12 -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/storage.js +18 -0
  10. package/dist/build-info.js +1 -1
  11. package/dist/pi-intercom/broker/broker.js +42 -29
  12. package/dist/pi-intercom/broker/client-last.d.ts +12 -1
  13. package/dist/pi-intercom/broker/client-last.js +25 -0
  14. package/dist/pi-intercom/broker/client-park.d.ts +9 -0
  15. package/dist/pi-intercom/broker/client-park.js +21 -0
  16. package/dist/pi-intercom/broker/client.d.ts +2 -3
  17. package/dist/pi-intercom/broker/client.js +23 -28
  18. package/dist/pi-intercom/broker/last.d.ts +5 -1
  19. package/dist/pi-intercom/broker/last.js +34 -12
  20. package/dist/pi-intercom/broker/parked.js +107 -0
  21. package/dist/pi-intercom/broker/server-connection.js +1 -1
  22. package/dist/pi-intercom/connection.js +65 -22
  23. package/dist/pi-intercom/inbound.js +11 -0
  24. package/dist/pi-intercom/index.js +38 -0
  25. package/dist/shared/parking.js +12 -0
  26. package/docs/code-quality.md +5 -5
  27. package/docs/development.md +3 -3
  28. package/docs/intercom.md +2 -1
  29. package/docs/performance.md +31 -3
  30. package/docs/reference.md +12 -1
  31. package/package.json +1 -1
  32. package/scripts/measure-size.mjs +1 -1
  33. package/src/agent/child-runtime.ts +105 -0
  34. package/src/agent/contracts.ts +10 -0
  35. package/src/agent/host.ts +22 -2
  36. package/src/agent/index.ts +10 -17
  37. package/src/agent/observation.ts +4 -0
  38. package/src/agent/parking.ts +341 -0
  39. package/src/agent/process.ts +41 -4
  40. package/src/agent/runner.ts +124 -144
  41. package/src/agent/storage.ts +17 -0
  42. package/src/pi-intercom/broker/broker.ts +44 -37
  43. package/src/pi-intercom/broker/client-last.ts +41 -1
  44. package/src/pi-intercom/broker/client-park.ts +23 -0
  45. package/src/pi-intercom/broker/client.ts +24 -28
  46. package/src/pi-intercom/broker/last.ts +49 -14
  47. package/src/pi-intercom/broker/parked.ts +130 -0
  48. package/src/pi-intercom/broker/server-connection.ts +2 -2
  49. package/src/pi-intercom/connection.ts +87 -34
  50. package/src/pi-intercom/inbound.ts +14 -0
  51. package/src/pi-intercom/index.ts +41 -0
  52. package/src/pi-intercom/last-message.ts +1 -1
  53. package/src/shared/parking.ts +22 -0
@@ -2,10 +2,12 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { commandSchema } from "./contracts.js";
4
4
  import { launchAt, missing, read, runDirectory, write } from "./storage.js";
5
- import { ownIdentity, ParentCheck, shellCommand, shells, signalProcess } from "./process.js";
5
+ import { ownIdentity, ParentCheck, shells, signalProcess } from "./process.js";
6
6
  import { errorText, loadSDK } from "./host.js";
7
+ import { Parking } from "./parking.js";
7
8
  import { childUsage } from "./usage.js";
8
- import { initializeCwd, localCwdOwner } from "./cwd.js";
9
+ import { initializeCwd } from "./cwd.js";
10
+ import { childRuntime } from "./child-runtime.js";
9
11
  import { ActorObservation } from "./observation.js";
10
12
  import { DirectoryWatch, Wake } from "./wake.js";
11
13
  function commandIdentity(file) {
@@ -29,28 +31,12 @@ function completionStatus(stopping, message, error) {
29
31
  }
30
32
  return "completed";
31
33
  }
32
- function guard(pi, directory, token) {
33
- pi.on("tool_call", (event) => {
34
- if (event.toolName === "delegate" || event.toolName === "agent_runs") {
35
- return { block: true, reason: "Only a main session may delegate or control children." };
36
- }
37
- const owner = pi.getAllTools().find((tool) => tool.name === "bash")?.sourceInfo;
38
- if (event.toolName === "bash" &&
39
- typeof event.input.command === "string" &&
40
- owner &&
41
- (owner.source === "builtin" || localCwdOwner(owner))) {
42
- // Preserve the selected local owner's cwd/env and leave remote/custom tools alone.
43
- const input = event.input;
44
- input.command = shellCommand(event.input.command, directory, token);
45
- }
46
- return;
47
- });
48
- }
49
34
  class Actor {
50
35
  active;
51
36
  stopping = false;
52
37
  stopDeadline;
53
38
  nextParentCheck = 0;
39
+ parking = false;
54
40
  parent = new ParentCheck();
55
41
  // Raised when durable commands may need admission: change hints and stop.
56
42
  wake = new Wake();
@@ -128,63 +114,6 @@ class Actor {
128
114
  .map((block) => block.text)
129
115
  .join("\n") ?? "");
130
116
  }
131
- createRuntime(sdk) {
132
- return async ({ sessionManager }) => {
133
- const launch = this.initial;
134
- if (!fs.existsSync(launch.sessionFile)) {
135
- throw new Error("Missing saved native session; refusing to create another.");
136
- }
137
- const settingsManager = sdk.SettingsManager.create(launch.cwd, launch.agentDir, {
138
- projectTrusted: launch.trusted,
139
- });
140
- const services = await sdk.createAgentSessionServices({
141
- cwd: launch.cwd,
142
- agentDir: launch.agentDir,
143
- settingsManager,
144
- resourceLoaderOptions: {
145
- additionalExtensionPaths: [launch.entry],
146
- extensionFactories: [
147
- {
148
- name: "codemode",
149
- builtin: true,
150
- factory: sdk.createCodemodeExtension({ mode: "on" }),
151
- },
152
- { name: "tool_search", builtin: true, factory: sdk.createToolSearchExtension() },
153
- { name: "mcp", builtin: true, factory: sdk.createMcpExtension() },
154
- (pi) => {
155
- guard(pi, this.dir, this.token);
156
- pi.on("session_start", (_event, ctx) => {
157
- this.context = { pi, ctx };
158
- });
159
- // Sparse tool lifecycle keeps the live view's tools even while nobody watches.
160
- const tool = (event) => this.observation?.tool(event);
161
- pi.on("tool_execution_start", tool);
162
- pi.on("tool_execution_update", tool);
163
- pi.on("tool_execution_end", tool);
164
- },
165
- ],
166
- },
167
- });
168
- const diagnostics = services.resourceLoader.getExtensions().errors;
169
- if (diagnostics.length > 0) {
170
- throw new Error(`Child extension load failed: ${JSON.stringify(diagnostics)}`);
171
- }
172
- const model = services.modelRuntime.getModel(launch.provider, launch.model);
173
- if (!model) {
174
- throw new Error(`Saved model ${launch.provider}/${launch.model} is unavailable; no fallback was used.`);
175
- }
176
- return {
177
- ...(await sdk.createAgentSessionFromServices({
178
- services,
179
- sessionManager,
180
- model,
181
- thinkingLevel: launch.thinking,
182
- })),
183
- services,
184
- diagnostics: services.diagnostics,
185
- };
186
- };
187
- }
188
117
  stop = () => {
189
118
  if (this.stopping) {
190
119
  return;
@@ -362,7 +291,7 @@ class Actor {
362
291
  return this.stopping && this.active === undefined && (this.session?.isIdle ?? true);
363
292
  }
364
293
  /** Durable command readiness, not elapsed time, defines admission. */
365
- async admitCommands() {
294
+ async admitCommands(runtime, parked) {
366
295
  // Queued commands create this directory; otherwise pending() does and the next pass arms.
367
296
  const directory = path.join(this.dir, "commands");
368
297
  const commands = new DirectoryWatch(directory, () => true, this.wake.raise);
@@ -374,6 +303,10 @@ class Actor {
374
303
  this.retry = false;
375
304
  this.pending().forEach((command) => this.accept(command));
376
305
  }
306
+ // oxlint-disable-next-line no-await-in-loop
307
+ if (await this.park(runtime, parked)) {
308
+ return;
309
+ }
377
310
  const polling = this.retry || this.stopping || watch === "unwatched";
378
311
  const tick = Math.max(1, this.nextParentCheck - performance.now());
379
312
  // oxlint-disable-next-line no-await-in-loop
@@ -386,6 +319,36 @@ class Actor {
386
319
  commands.close();
387
320
  }
388
321
  }
322
+ /** Parks a final-idle actor; the parent/stop owner stays live while preparation awaits. */
323
+ async park(runtime, parked) {
324
+ const owner = new AbortController();
325
+ const watch = setInterval(() => {
326
+ if (this.shouldExit() || this.stopping) {
327
+ owner.abort();
328
+ }
329
+ }, 100);
330
+ try {
331
+ this.parking = await parked.park(runtime, {
332
+ signal: owner.signal,
333
+ pi: this.context?.pi,
334
+ busy: this.stopping || this.active !== undefined || this.observation?.watched === true,
335
+ // Raced commands and viewers keep this actor resident; the loop admits after release.
336
+ quiet: () => {
337
+ const queued = this.pending().some((command) => this.commandLaunch(command));
338
+ if (queued) {
339
+ this.wake.raise();
340
+ }
341
+ const idle = this.active === undefined && !this.stopping;
342
+ return !queued && idle && this.observation?.watched !== true;
343
+ },
344
+ errors: this.errors,
345
+ });
346
+ }
347
+ finally {
348
+ clearInterval(watch);
349
+ }
350
+ return this.parking;
351
+ }
389
352
  failure(error) {
390
353
  const launch = this.current ?? this.initial;
391
354
  const result = {
@@ -402,69 +365,104 @@ class Actor {
402
365
  }
403
366
  process.exitCode = 1;
404
367
  }
368
+ async start(sdk, parked) {
369
+ const sessionManager = parked.open();
370
+ const factory = childRuntime(sdk, this.initial, {
371
+ directory: this.dir,
372
+ token: this.token,
373
+ saved: parked.saved,
374
+ started: (pi, ctx) => {
375
+ this.context = { pi, ctx };
376
+ },
377
+ tool: (event) => this.observation?.tool(event),
378
+ });
379
+ const runtime = await sdk.createAgentSessionRuntime(factory, {
380
+ cwd: this.initial.cwd,
381
+ agentDir: this.initial.agentDir,
382
+ sessionManager,
383
+ });
384
+ try {
385
+ await this.ready(runtime, parked);
386
+ }
387
+ catch (error) {
388
+ // A runtime that never became ready is still disposed, so the failed actor exits.
389
+ await runtime.dispose();
390
+ throw error;
391
+ }
392
+ return runtime;
393
+ }
394
+ async ready(runtime, parked) {
395
+ this.session = runtime.session;
396
+ await this.session.bindExtensions({
397
+ mode: "print",
398
+ onError: (error) => {
399
+ this.errors.push(error.error);
400
+ },
401
+ });
402
+ if (!this.context) {
403
+ throw new Error("Child extension context is unavailable.");
404
+ }
405
+ initializeCwd(this.context.pi, this.context.ctx, this.initial.cwd, this.dir);
406
+ if (this.initial.name !== undefined && !parked.saved) {
407
+ this.session.sessionManager.appendSessionInfo(this.initial.name);
408
+ }
409
+ // Optional live view, bound before readiness; its faults never change native execution.
410
+ this.observation = await ActorObservation.open(this.session, {
411
+ token: this.token,
412
+ rootId: this.initial.rootId,
413
+ sessionId: this.session.sessionManager.getSessionId(),
414
+ });
415
+ write(path.join(this.dir, "actor.lock", "process.json"), {
416
+ ...ownIdentity(),
417
+ token: this.token,
418
+ });
419
+ write(path.join(this.dir, "ready.json"), {
420
+ pid: process.pid,
421
+ token: this.token,
422
+ sessionId: this.session.sessionManager.getSessionId(),
423
+ at: Date.now(),
424
+ });
425
+ parked.adopt();
426
+ }
405
427
  async serve() {
406
428
  const sdk = await loadSDK(this.initial.sdk);
429
+ const parked = new Parking(this.base, this.initial, this.token, sdk);
407
430
  let runtime;
408
431
  try {
409
- runtime = await sdk.createAgentSessionRuntime(this.createRuntime(sdk), {
410
- cwd: this.initial.cwd,
411
- agentDir: this.initial.agentDir,
412
- sessionManager: sdk.SessionManager.open(this.initial.sessionFile, undefined, this.initial.cwd),
413
- });
414
- this.session = runtime.session;
415
- await this.session.bindExtensions({
416
- mode: "print",
417
- onError: (error) => {
418
- this.errors.push(error.error);
419
- },
420
- });
421
- if (!this.context) {
422
- throw new Error("Child extension context is unavailable.");
423
- }
424
- initializeCwd(this.context.pi, this.context.ctx, this.initial.cwd, this.dir);
425
- if (this.initial.name !== undefined) {
426
- this.session.sessionManager.appendSessionInfo(this.initial.name);
427
- }
428
- // Optional live view, bound before readiness; its faults never change native execution.
429
- this.observation = await ActorObservation.open(this.session, {
430
- token: this.token,
431
- rootId: this.initial.rootId,
432
- sessionId: this.session.sessionManager.getSessionId(),
433
- });
434
- write(path.join(this.dir, "actor.lock", "process.json"), {
435
- ...ownIdentity(),
436
- token: this.token,
437
- });
438
- write(path.join(this.dir, "ready.json"), {
439
- pid: process.pid,
440
- token: this.token,
441
- sessionId: this.session.sessionManager.getSessionId(),
442
- at: Date.now(),
443
- });
432
+ runtime = await this.start(sdk, parked);
444
433
  process.on("SIGTERM", this.stop);
445
434
  process.on("SIGINT", this.stop);
446
- await this.admitCommands();
435
+ await this.admitCommands(runtime, parked);
447
436
  await this.active;
448
437
  }
449
438
  catch (error) {
450
439
  this.failure(error);
451
440
  }
452
441
  finally {
453
- process.off("SIGTERM", this.stop);
454
- process.off("SIGINT", this.stop);
455
- const observation = this.observation?.close();
442
+ await this.finish(runtime);
443
+ }
444
+ }
445
+ async finish(runtime) {
446
+ process.off("SIGTERM", this.stop);
447
+ process.off("SIGINT", this.stop);
448
+ const observation = this.observation?.close();
449
+ if (!this.parking) {
456
450
  await runtime?.dispose();
457
- if (this.stopping) {
458
- this.stopShells("SIGKILL");
459
- }
460
- clearTimeout(this.stopDeadline);
461
- write(path.join(this.dir, `shutdown-${this.token}.json`), {
462
- at: Date.now(),
463
- errors: this.errors,
464
- ...(observation === undefined ? {} : { observation }),
465
- });
466
- // Keep the process claim until a launcher proves this exact actor is gone.
467
451
  }
452
+ if (this.stopping) {
453
+ this.stopShells("SIGKILL");
454
+ }
455
+ clearTimeout(this.stopDeadline);
456
+ write(path.join(this.dir, `shutdown-${this.token}.json`), {
457
+ at: Date.now(),
458
+ errors: this.errors,
459
+ ...(observation === undefined ? {} : { observation }),
460
+ });
461
+ if (this.parking) {
462
+ // Parked authority requires actual exit; no leftover extension handle may keep it alive.
463
+ process.exit();
464
+ }
465
+ // Keep the process claim until a launcher proves this exact actor is gone.
468
466
  }
469
467
  }
470
468
  const base = process.argv.at(2);
@@ -82,6 +82,24 @@ export function launchAt(base, id, owner) {
82
82
  }
83
83
  return launch;
84
84
  }
85
+ /** Claims the single successor of a saved run; undefined when another caller already has. */
86
+ export function successor(base, launch, prompt) {
87
+ const id = randomUUID();
88
+ const next = { ...launch, id, predecessor: launch.id, prompt, createdAt: Date.now() };
89
+ fs.mkdirSync(runDirectory(base, id), { mode: 0o700 });
90
+ write(path.join(runDirectory(base, id), "launch.json"), next, true);
91
+ try {
92
+ write(path.join(runDirectory(base, launch.id), "successor.json"), { id }, true);
93
+ }
94
+ catch (error) {
95
+ fs.rmSync(runDirectory(base, id), { recursive: true });
96
+ if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) {
97
+ throw error;
98
+ }
99
+ return undefined;
100
+ }
101
+ return next;
102
+ }
85
103
  export function latest(base, initial) {
86
104
  const seen = new Set();
87
105
  let launch = initial;
@@ -1 +1 @@
1
- export const EXTENSION_BUILD = Object.freeze({"version":"0.51.0","sha256":"fc084c36d416e6ea9947600b7d1fdef98c84a82f3408dea2ea028a9c485ac31e"});
1
+ export const EXTENSION_BUILD = Object.freeze({"version":"0.52.0","sha256":"a9faf29451051826907d9343b432c9232529ccaa68c75d681dbcb464702fc25f"});
@@ -8,6 +8,7 @@ import { brokerPidRecord } from "./pid.js";
8
8
  import { assertBrokerCanStart } from "./spawn.js";
9
9
  import { LastMessageRelay } from "./last.js";
10
10
  import { canSeeSession, findSessions } from "./targets.js";
11
+ import { ParkedSessions, reachable } from "./parked.js";
11
12
  import { acceptBrokerConnection, registeredSessionInfo } from "./server-connection.js";
12
13
  import { guardedDeliveryError } from "./guard.js";
13
14
  import { compactTopicMessage, normalizeMessage, normalizeSessionInfo, } from "../types.js";
@@ -18,6 +19,7 @@ const REPLACE_DELIVERY_DELAY_MS = 1500;
18
19
  class IntercomBroker {
19
20
  sessions = new Map();
20
21
  lastMessages = new LastMessageRelay(this.sessions);
22
+ parking = new ParkedSessions(this.sessions, this.deliverMessage.bind(this));
21
23
  pendingReplaceDeliveries = new Map();
22
24
  server;
23
25
  shutdownTimer = null;
@@ -56,7 +58,7 @@ class IntercomBroker {
56
58
  }
57
59
  this.shutdownTimer = setTimeout(() => {
58
60
  this.shutdownTimer = null;
59
- if (this.sessions.size === 0) {
61
+ if (this.parking.exited) {
60
62
  console.log("No sessions connected, shutting down");
61
63
  this.shutdown();
62
64
  }
@@ -84,7 +86,7 @@ class IntercomBroker {
84
86
  }
85
87
  switch (msg.type) {
86
88
  case "unregister":
87
- this.removeSession(currentId);
89
+ this.removeSession(currentId, socket);
88
90
  setId(null);
89
91
  break;
90
92
  case "list":
@@ -94,7 +96,8 @@ class IntercomBroker {
94
96
  this.send(socket, msg, currentId);
95
97
  break;
96
98
  case "last_request":
97
- this.lastMessages.request(currentId, msg);
99
+ // Reads never spawn a parked child.
100
+ this.lastMessages.request(currentId, msg, (id) => this.parking.reader(id));
98
101
  break;
99
102
  case "last_response":
100
103
  this.lastMessages.respond(currentId, msg);
@@ -102,21 +105,28 @@ class IntercomBroker {
102
105
  case "presence":
103
106
  this.presence(msg, currentId);
104
107
  break;
108
+ case "park":
109
+ case "unpark":
110
+ this.parking.park(socket, currentId, msg.type === "park");
111
+ break;
105
112
  default:
106
113
  throw new Error(`Unknown client message type: ${msg.type}`);
107
114
  }
108
115
  }
109
116
  register(socket, msg, setId) {
110
- const info = registeredSessionInfo(msg, this.sessions);
111
- const id = info.id;
117
+ const registered = registeredSessionInfo(msg, this.sessions);
118
+ const { id, resumed } = this.parking.claim(msg.requestedId, registered);
119
+ const info = { ...registered, id };
112
120
  snapshotFrames(randomUUID(), [info], true);
113
121
  const topicFrames = msg.topicFrames === true;
114
122
  this.sessions.set(id, {
115
123
  socket,
116
124
  info: messageSender(info),
117
- topics: new TopicSnapshot(info),
125
+ // A woken generation registers no topics; the parked identity's owner keeps them.
126
+ topics: resumed?.topics ?? new TopicSnapshot(info),
118
127
  topicFrames,
119
128
  lastMessages: msg.lastMessages === true,
129
+ parking: msg.parking === true,
120
130
  receiptReads: msg.receiptReads === 1,
121
131
  });
122
132
  setId(id);
@@ -128,20 +138,24 @@ class IntercomBroker {
128
138
  type: "registered",
129
139
  sessionId: id,
130
140
  lastMessages: true,
141
+ parking: msg.parking === true,
131
142
  receiptReads: 1,
132
143
  ...(msg.guardedDelivery === 1 ? { guardedDelivery: 1 } : {}),
133
144
  ...(topicFrames ? { topicsSupported: true, topicFrames: true } : {}),
134
145
  });
146
+ this.parking.registered(id, resumed);
135
147
  }
136
- removeSession(sessionId) {
148
+ removeSession(sessionId, socket) {
137
149
  const session = this.sessions.get(sessionId);
138
- if (!session) {
150
+ if (session?.socket !== socket) {
139
151
  return;
140
152
  }
141
- this.sessions.delete(sessionId);
142
153
  this.lastMessages.disconnected(sessionId);
143
- this.clearPendingReplaceDeliveries(sessionId);
144
- this.broadcast({ type: "session_left", sessionId }, session.info);
154
+ if (!this.parking.closed(session)) {
155
+ this.sessions.delete(sessionId);
156
+ this.clearPendingReplaceDeliveries(sessionId);
157
+ this.broadcast({ type: "session_left", sessionId }, session.info);
158
+ }
145
159
  this.scheduleShutdownCheck();
146
160
  }
147
161
  planPublications(fromId, from, publication) {
@@ -155,7 +169,7 @@ class IntercomBroker {
155
169
  continue;
156
170
  }
157
171
  const message = publicationMessage(publication, subscription.awaitRelease === true);
158
- const error = this.validateDeliveryPayload(from, message, target.topicFrames);
172
+ const error = this.validateDeliveryPayload(from, message, target);
159
173
  if (error !== null) {
160
174
  throw new Error(error);
161
175
  }
@@ -307,14 +321,16 @@ class IntercomBroker {
307
321
  }
308
322
  sendToSession(fromId, toId, from, message) {
309
323
  const target = this.sessions.get(toId);
310
- if (target && shouldReplaceDelivery(message, target.info)) {
324
+ if (target && target.parked === undefined && shouldReplaceDelivery(message, target.info)) {
311
325
  return this.queueReplaceDelivery(fromId, toId, from, message);
312
326
  }
313
327
  const reason = this.deliverMessage(toId, from, message);
328
+ const queued = reason === null && target?.parked !== undefined;
314
329
  return {
315
330
  id: message.id,
316
331
  accepted: reason === null,
317
- delivered: reason === null,
332
+ delivered: reason === null && !queued,
333
+ ...(queued ? { queued } : {}),
318
334
  ...(reason !== null ? { reason } : {}),
319
335
  };
320
336
  }
@@ -335,7 +351,7 @@ class IntercomBroker {
335
351
  return `${fromId}\0${toId}\0${threadId}`;
336
352
  }
337
353
  queueReplaceDelivery(fromId, toId, from, message) {
338
- const validationError = this.validateDeliveryPayload(from, message, this.sessions.get(toId)?.topicFrames === true);
354
+ const validationError = this.validateDeliveryPayload(from, message, this.sessions.get(toId));
339
355
  if (validationError !== null) {
340
356
  return { id: message.id, accepted: false, delivered: false, reason: validationError };
341
357
  }
@@ -367,28 +383,25 @@ class IntercomBroker {
367
383
  }
368
384
  }
369
385
  }
370
- validateDeliveryPayload(from, message, topicFrames) {
386
+ /** One admission rule; retained input is checked as the uncompacted envelope any later
387
+ * transport generation accepts. */
388
+ validateDeliveryPayload(from, message, target) {
389
+ const compact = target?.parked === undefined && target?.topicFrames === true;
371
390
  return (validateIntercomMessageSize({
372
391
  type: "message",
373
392
  from: messageSender(from),
374
- message: topicFrames ? compactTopicMessage(message) : message,
393
+ message: compact ? compactTopicMessage(message) : message,
375
394
  })?.message ?? null);
376
395
  }
377
396
  deliverMessage(toId, from, message) {
378
- const guardError = guardedDeliveryError(this.sessions, toId, message);
379
- if (guardError !== null) {
380
- return guardError;
381
- }
382
397
  const target = this.sessions.get(toId);
383
- if (!target ||
384
- target.socket.destroyed ||
385
- target.socket.writableEnded ||
386
- !target.socket.writable) {
387
- return "Recipient disconnected before delivery";
398
+ const refused = guardedDeliveryError(this.sessions, toId, message) ??
399
+ this.validateDeliveryPayload(from, message, target);
400
+ if (refused !== null || this.parking.retain(target, from, message)) {
401
+ return refused;
388
402
  }
389
- const validationError = this.validateDeliveryPayload(from, message, target.topicFrames);
390
- if (validationError !== null) {
391
- return validationError;
403
+ if (!reachable(target)) {
404
+ return "Recipient disconnected before delivery";
392
405
  }
393
406
  this.touchActivity(toId, true);
394
407
  try {
@@ -1,7 +1,18 @@
1
1
  import type net from "node:net";
2
2
  import { type UnknownRecord } from "../../shared/unknown.ts";
3
- import type { LastMessageQuery, LastMessageResult, ReceiptQuery, ReceiptResult } from "../types.ts";
3
+ import { type NativeReadQuery } from "./last.ts";
4
+ import { type LastMessageQuery, type LastMessageResult, type ReceiptQuery, type ReceiptResult, type SessionInfo } from "../types.ts";
5
+ /** A peer's read of this session or, for a main, of its exited `parked` child. */
6
+ export declare function lastRequest(msg: UnknownRecord): {
7
+ readonly requestId: string;
8
+ readonly from: SessionInfo;
9
+ readonly query: NativeReadQuery;
10
+ readonly parked?: SessionInfo;
11
+ };
4
12
  export declare class LastMessageRequests {
13
+ /** Negotiated at registration; a failed connection supports neither. */
14
+ lastMessages: boolean;
15
+ receiptReads: boolean;
5
16
  private readonly timeoutMs;
6
17
  constructor(timeoutMs: number);
7
18
  private readonly pending;
@@ -1,14 +1,33 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { errorMessage } from "../../shared/unknown.js";
3
3
  import { isNativeReadQuery, isNativeReadResult, isLastMessageResult, isReceiptResult, } from "./last.js";
4
+ import { normalizeSessionInfo, } from "../types.js";
4
5
  import { writeMessage } from "./framing.js";
6
+ /** A peer's read of this session or, for a main, of its exited `parked` child. */
7
+ export function lastRequest(msg) {
8
+ const from = normalizeSessionInfo(msg.from);
9
+ if (typeof msg.requestId !== "string" || !from || !isNativeReadQuery(msg.query)) {
10
+ throw new Error("Invalid last-message request");
11
+ }
12
+ return {
13
+ requestId: msg.requestId,
14
+ from,
15
+ query: msg.query,
16
+ parked: normalizeSessionInfo(msg.parked) ?? undefined,
17
+ };
18
+ }
5
19
  export class LastMessageRequests {
20
+ /** Negotiated at registration; a failed connection supports neither. */
21
+ lastMessages = false;
22
+ receiptReads = false;
6
23
  timeoutMs;
7
24
  constructor(timeoutMs) {
8
25
  this.timeoutMs = timeoutMs;
9
26
  }
10
27
  pending = new Map();
11
28
  fail(error) {
29
+ this.lastMessages = false;
30
+ this.receiptReads = false;
12
31
  for (const pending of this.pending.values()) {
13
32
  clearTimeout(pending.timer);
14
33
  pending.reject(error);
@@ -36,9 +55,15 @@ export class LastMessageRequests {
36
55
  return isReceiptResult(result) ? result : { error: "Invalid receipt response" };
37
56
  }
38
57
  request(socket, to, query) {
58
+ if (!this.lastMessages) {
59
+ return Promise.reject(new Error("This broker does not support last-message reads; reload after it exits."));
60
+ }
39
61
  if (!isNativeReadQuery(query)) {
40
62
  return Promise.reject(new Error("Invalid last-message query"));
41
63
  }
64
+ if ("messageId" in query && !this.receiptReads) {
65
+ return Promise.reject(new Error("publication_unavailable"));
66
+ }
42
67
  return new Promise((resolve, reject) => {
43
68
  const requestId = randomUUID();
44
69
  const timer = setTimeout(() => {
@@ -0,0 +1,9 @@
1
+ import type net from "node:net";
2
+ /** Parking negotiation and its single outstanding broker acknowledgement. */
3
+ export declare class ParkRequests {
4
+ supported: boolean;
5
+ private acknowledged?;
6
+ settle(parked: boolean): void;
7
+ /** True once the broker retains later input; false when it cannot or the socket closed. */
8
+ request(socket: () => net.Socket, on: boolean): Promise<boolean>;
9
+ }
@@ -0,0 +1,21 @@
1
+ import { writeMessage } from "./framing.js";
2
+ /** Parking negotiation and its single outstanding broker acknowledgement. */
3
+ export class ParkRequests {
4
+ supported = false;
5
+ acknowledged;
6
+ settle(parked) {
7
+ this.acknowledged?.(parked);
8
+ }
9
+ /** True once the broker retains later input; false when it cannot or the socket closed. */
10
+ request(socket, on) {
11
+ return new Promise((resolve) => {
12
+ this.acknowledged = on && this.supported ? resolve : undefined;
13
+ if (this.supported) {
14
+ writeMessage(socket(), { type: on ? "park" : "unpark" });
15
+ }
16
+ if (this.acknowledged === undefined) {
17
+ resolve(false);
18
+ }
19
+ });
20
+ }
21
+ }
@@ -11,8 +11,7 @@ export declare class IntercomClient extends EventEmitter {
11
11
  private connection;
12
12
  private _sessionId;
13
13
  private _topicsSupported;
14
- private lastMessagesSupported;
15
- private receiptReadsSupported;
14
+ private readonly parking;
16
15
  private guardedDeliverySupported;
17
16
  private readonly lastRequests;
18
17
  private readonly pendingSends;
@@ -36,11 +35,11 @@ export declare class IntercomClient extends EventEmitter {
36
35
  private receiveMessage;
37
36
  private receiveDelivery;
38
37
  private receiveSessions;
38
+ park(on?: boolean): Promise<boolean>;
39
39
  disconnect(): Promise<void>;
40
40
  listSessions(): Promise<SessionInfo[]>;
41
41
  last(to: string, query?: LastMessageQuery): Promise<LastMessageResult>;
42
42
  receipt(to: string, query: ReceiptQuery): Promise<ReceiptResult>;
43
- private readSocket;
44
43
  respondLast(requestId: string, result: unknown): void;
45
44
  updateTopics(change: TopicChange, onAccepted?: (snapshot: SessionSnapshot) => void): Promise<SessionSnapshot>;
46
45
  private requestSessions;