@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
@@ -6,12 +6,42 @@ import {
6
6
  isNativeReadResult,
7
7
  isLastMessageResult,
8
8
  isReceiptResult,
9
+ type NativeReadQuery,
9
10
  type NativeReadResult,
10
11
  } from "./last.ts";
11
- import type { LastMessageQuery, LastMessageResult, ReceiptQuery, ReceiptResult } from "../types.ts";
12
+ import {
13
+ normalizeSessionInfo,
14
+ type LastMessageQuery,
15
+ type LastMessageResult,
16
+ type ReceiptQuery,
17
+ type ReceiptResult,
18
+ type SessionInfo,
19
+ } from "../types.ts";
12
20
  import { writeMessage } from "./framing.ts";
13
21
 
22
+ /** A peer's read of this session or, for a main, of its exited `parked` child. */
23
+ export function lastRequest(msg: UnknownRecord): {
24
+ readonly requestId: string;
25
+ readonly from: SessionInfo;
26
+ readonly query: NativeReadQuery;
27
+ readonly parked?: SessionInfo;
28
+ } {
29
+ const from = normalizeSessionInfo(msg.from);
30
+ if (typeof msg.requestId !== "string" || !from || !isNativeReadQuery(msg.query)) {
31
+ throw new Error("Invalid last-message request");
32
+ }
33
+ return {
34
+ requestId: msg.requestId,
35
+ from,
36
+ query: msg.query,
37
+ parked: normalizeSessionInfo(msg.parked) ?? undefined,
38
+ };
39
+ }
40
+
14
41
  export class LastMessageRequests {
42
+ /** Negotiated at registration; a failed connection supports neither. */
43
+ lastMessages = false;
44
+ receiptReads = false;
15
45
  private readonly timeoutMs: number;
16
46
  constructor(timeoutMs: number) {
17
47
  this.timeoutMs = timeoutMs;
@@ -26,6 +56,8 @@ export class LastMessageRequests {
26
56
  >();
27
57
 
28
58
  fail(error: Readonly<Error>): void {
59
+ this.lastMessages = false;
60
+ this.receiptReads = false;
29
61
  for (const pending of this.pending.values()) {
30
62
  clearTimeout(pending.timer);
31
63
  pending.reject(error);
@@ -55,9 +87,17 @@ export class LastMessageRequests {
55
87
  return isReceiptResult(result) ? result : { error: "Invalid receipt response" };
56
88
  }
57
89
  private request(socket: net.Socket, to: string, query: unknown): Promise<NativeReadResult> {
90
+ if (!this.lastMessages) {
91
+ return Promise.reject(
92
+ new Error("This broker does not support last-message reads; reload after it exits."),
93
+ );
94
+ }
58
95
  if (!isNativeReadQuery(query)) {
59
96
  return Promise.reject(new Error("Invalid last-message query"));
60
97
  }
98
+ if ("messageId" in query && !this.receiptReads) {
99
+ return Promise.reject(new Error("publication_unavailable"));
100
+ }
61
101
  return new Promise((resolve, reject) => {
62
102
  const requestId = randomUUID();
63
103
  const timer = setTimeout(() => {
@@ -0,0 +1,23 @@
1
+ import type net from "node:net";
2
+ import { writeMessage } from "./framing.ts";
3
+
4
+ /** Parking negotiation and its single outstanding broker acknowledgement. */
5
+ export class ParkRequests {
6
+ supported = false;
7
+ private acknowledged?: (parked: boolean) => void;
8
+ settle(parked: boolean): void {
9
+ this.acknowledged?.(parked);
10
+ }
11
+ /** True once the broker retains later input; false when it cannot or the socket closed. */
12
+ request(socket: () => net.Socket, on: boolean): Promise<boolean> {
13
+ return new Promise((resolve) => {
14
+ this.acknowledged = on && this.supported ? resolve : undefined;
15
+ if (this.supported) {
16
+ writeMessage(socket(), { type: on ? "park" : "unpark" });
17
+ }
18
+ if (this.acknowledged === undefined) {
19
+ resolve(false);
20
+ }
21
+ });
22
+ }
23
+ }
@@ -4,8 +4,9 @@ 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 { LastMessageRequests } from "./client-last.ts";
8
- import { isNativeReadQuery, isNativeReadResult, type NativeReadQuery } from "./last.ts";
7
+ import { LastMessageRequests, lastRequest } from "./client-last.ts";
8
+ import { ParkRequests } from "./client-park.ts";
9
+ import { isNativeReadResult } from "./last.ts";
9
10
  import {
10
11
  errorMessage,
11
12
  isRecord,
@@ -64,8 +65,7 @@ export class IntercomClient extends EventEmitter {
64
65
  private connection: BrokerConnection | null = null;
65
66
  private _sessionId: string | null = null;
66
67
  private _topicsSupported = false;
67
- private lastMessagesSupported = false;
68
- private receiptReadsSupported = false;
68
+ private readonly parking = new ParkRequests();
69
69
  private guardedDeliverySupported = false;
70
70
  private readonly lastRequests: LastMessageRequests;
71
71
  private readonly pendingSends = new Map<
@@ -156,8 +156,7 @@ export class IntercomClient extends EventEmitter {
156
156
  this.connection = null;
157
157
  this._sessionId = null;
158
158
  this._topicsSupported = false;
159
- this.lastMessagesSupported = false;
160
- this.receiptReadsSupported = false;
159
+ this.parking.settle(false);
161
160
  this.guardedDeliverySupported = false;
162
161
  this.disconnecting = false;
163
162
  this.failPending(error);
@@ -175,6 +174,7 @@ export class IntercomClient extends EventEmitter {
175
174
  requestedId,
176
175
  topicFrames: true,
177
176
  lastMessages: true,
177
+ parking: true,
178
178
  receiptReads: 1,
179
179
  guardedDelivery: 1,
180
180
  });
@@ -241,11 +241,15 @@ export class IntercomClient extends EventEmitter {
241
241
  case "delivery_failed":
242
242
  this.receiveDelivery(brokerMessage);
243
243
  break;
244
+ case "parked":
245
+ this.parking.settle(true);
246
+ break;
247
+ case "wake":
244
248
  case "session_left":
245
249
  if (typeof brokerMessage.sessionId !== "string") {
246
- throw new Error("Invalid session_left message");
250
+ throw new Error(`Invalid ${type} message`);
247
251
  }
248
- this.emit("session_left", brokerMessage.sessionId);
252
+ this.emit(type, brokerMessage.sessionId);
249
253
  break;
250
254
  default:
251
255
  throw new Error(`Unknown broker message type: ${type}`);
@@ -261,20 +265,18 @@ export class IntercomClient extends EventEmitter {
261
265
  }
262
266
  this._sessionId = msg.sessionId;
263
267
  this._topicsSupported = msg.topicsSupported === true && msg.topicFrames === true;
264
- this.lastMessagesSupported = msg.lastMessages === true;
265
- this.receiptReadsSupported = msg.receiptReads === 1;
268
+ this.lastRequests.lastMessages = msg.lastMessages === true;
269
+ this.parking.supported = msg.parking === true;
270
+ this.lastRequests.receiptReads = msg.receiptReads === 1;
266
271
  this.guardedDeliverySupported = msg.guardedDelivery === 1;
267
272
  this.connection?.confirmRegistration();
268
273
  this.emit("_registered", { type: "registered", sessionId: msg.sessionId });
269
274
  }
270
275
 
271
276
  private receiveLastRequest(msg: UnknownRecord): void {
272
- const from = normalizeSessionInfo(msg.from);
273
- if (typeof msg.requestId !== "string" || !from || !isNativeReadQuery(msg.query)) {
274
- throw new Error("Invalid last-message request");
275
- }
276
- if (!this.emit("last_request", { requestId: msg.requestId, from, query: msg.query })) {
277
- this.respondLast(msg.requestId, { error: "Recipient has no last-message reader." });
277
+ const request = lastRequest(msg);
278
+ if (!this.emit("last_request", request)) {
279
+ this.respondLast(request.requestId, { error: "Recipient has no last-message reader." });
278
280
  }
279
281
  }
280
282
 
@@ -335,6 +337,10 @@ export class IntercomClient extends EventEmitter {
335
337
  }
336
338
  }
337
339
 
340
+ park(on = true): Promise<boolean> {
341
+ return this.parking.request(() => this.requireActiveSocket(), on);
342
+ }
343
+
338
344
  async disconnect(): Promise<void> {
339
345
  const connection = this.connection;
340
346
  if (!connection) {
@@ -350,20 +356,10 @@ export class IntercomClient extends EventEmitter {
350
356
  }
351
357
 
352
358
  async last(to: string, query: LastMessageQuery = {}): Promise<LastMessageResult> {
353
- return await this.lastRequests.last(this.readSocket(query), to, query);
359
+ return await this.lastRequests.last(this.requireActiveSocket(), to, query);
354
360
  }
355
361
  async receipt(to: string, query: ReceiptQuery): Promise<ReceiptResult> {
356
- return await this.lastRequests.receipt(this.readSocket(query), to, query);
357
- }
358
- private readSocket(query: NativeReadQuery): net.Socket {
359
- const socket = this.requireActiveSocket();
360
- if (!this.lastMessagesSupported) {
361
- throw new Error("This broker does not support last-message reads; reload after it exits.");
362
- }
363
- if ("messageId" in query && !this.receiptReadsSupported) {
364
- throw new Error("publication_unavailable");
365
- }
366
- return socket;
362
+ return await this.lastRequests.receipt(this.requireActiveSocket(), to, query);
367
363
  }
368
364
 
369
365
  respondLast(requestId: string, result: unknown): void {
@@ -100,19 +100,37 @@ interface LastPeer {
100
100
  readonly info: SessionInfo;
101
101
  readonly lastMessages: boolean;
102
102
  readonly receiptReads: boolean;
103
+ readonly parked?: { readonly closed: boolean };
103
104
  }
104
105
  interface PendingLast {
105
106
  readonly fromId: string;
106
107
  readonly toId: string;
108
+ /** A parked child's main answers from the child's captured native history. */
109
+ readonly via?: string;
107
110
  readonly requestId: string;
108
111
  readonly query: NativeReadQuery;
109
112
  readonly timer: NodeJS.Timeout;
110
113
  }
114
+ /** An exited parked child answers nothing itself; only its main holds the captured history. */
115
+ function exitedRefusal(
116
+ target: LastPeer,
117
+ query: NativeReadQuery,
118
+ via?: LastPeer,
119
+ ): string | undefined {
120
+ if (target.parked?.closed !== true) {
121
+ return undefined;
122
+ }
123
+ // No loaded journal owner answers receipts, with or without its main.
124
+ if ("messageId" in query) {
125
+ return "publication_unavailable";
126
+ }
127
+ return via === undefined ? "Parked session history is unavailable." : undefined;
128
+ }
111
129
  function unavailable(
112
130
  targets: readonly LastPeer[],
113
131
  name: string,
114
132
  pending: number,
115
- query: NativeReadQuery,
133
+ { query, via }: { readonly query: NativeReadQuery; readonly via?: LastPeer },
116
134
  ): string | undefined {
117
135
  if (targets.length === 0) {
118
136
  return "Session not found";
@@ -120,6 +138,10 @@ function unavailable(
120
138
  if (targets.length > 1) {
121
139
  return `Multiple sessions named "${name}" are connected. Use the session ID instead.`;
122
140
  }
141
+ const exited = exitedRefusal(targets[0], query, via);
142
+ if (exited !== undefined) {
143
+ return exited;
144
+ }
123
145
  if (!targets[0].lastMessages) {
124
146
  return "Recipient does not support last-message reads; reload its extension.";
125
147
  }
@@ -164,6 +186,17 @@ function matchesLastPage(
164
186
  );
165
187
  }
166
188
 
189
+ function lastRequest(message: UnknownRecord): {
190
+ readonly requestId: string;
191
+ readonly to: string;
192
+ readonly query: NativeReadQuery;
193
+ } {
194
+ const { requestId, to, query } = message;
195
+ if (typeof requestId !== "string" || typeof to !== "string" || !isNativeReadQuery(query)) {
196
+ throw new Error("Invalid last message request");
197
+ }
198
+ return { requestId, to, query };
199
+ }
167
200
  /** Correlation belongs to the broker, not the requester or the recipient's model queue. */
168
201
  export class LastMessageRelay {
169
202
  private readonly pending = new Map<string, PendingLast>();
@@ -172,14 +205,13 @@ export class LastMessageRelay {
172
205
  this.sessions = sessions;
173
206
  }
174
207
 
175
- request(fromId: string, message: UnknownRecord): void {
176
- if (
177
- typeof message.requestId !== "string" ||
178
- typeof message.to !== "string" ||
179
- !isNativeReadQuery(message.query)
180
- ) {
181
- throw new Error("Invalid last message request");
182
- }
208
+ /** `reader` names another peer that answers for an exited target from its captured history. */
209
+ request(
210
+ fromId: string,
211
+ input: UnknownRecord,
212
+ reader: (id: string) => LastPeer | undefined,
213
+ ): void {
214
+ const message = lastRequest(input);
183
215
  const targets = findSessions(this.sessions, message.to, fromId);
184
216
  const target = targets[0];
185
217
  const from = this.sessions.get(fromId);
@@ -187,14 +219,16 @@ export class LastMessageRelay {
187
219
  return;
188
220
  }
189
221
  const token = randomUUID();
222
+ const via = targets.length === 1 ? reader(target.info.id) : undefined;
190
223
  const frame = {
191
224
  type: "last_request" as const,
192
225
  requestId: token,
193
226
  from: messageSender(from.info),
194
227
  query: message.query,
228
+ parked: via && messageSender(target.info),
195
229
  };
196
230
  const error =
197
- unavailable(targets, message.to, this.pending.size, message.query) ??
231
+ unavailable(targets, message.to, this.pending.size, { query: message.query, via }) ??
198
232
  validateIntercomMessageSize(frame)?.message;
199
233
  if (error !== undefined) {
200
234
  writeMessage(from.socket, {
@@ -212,12 +246,13 @@ export class LastMessageRelay {
212
246
  this.pending.set(token, {
213
247
  fromId,
214
248
  toId: target.info.id,
249
+ via: via?.info.id,
215
250
  requestId: message.requestId,
216
251
  query: message.query,
217
252
  timer,
218
253
  });
219
254
  try {
220
- writeMessage(target.socket, frame);
255
+ writeMessage((via ?? target).socket, frame);
221
256
  } catch {
222
257
  this.finish(token, { error: "Recipient disconnected before last-message read" });
223
258
  }
@@ -228,13 +263,13 @@ export class LastMessageRelay {
228
263
  throw new Error("Invalid last message response");
229
264
  }
230
265
  const pending = this.pending.get(message.requestId);
231
- if (!pending || pending.toId !== fromId) {
266
+ if (!pending || (pending.via ?? pending.toId) !== fromId) {
232
267
  return;
233
268
  }
234
269
  const result = message.result;
235
270
  this.finish(
236
271
  message.requestId,
237
- isNativeReadResult(result) && matchesPage(result, pending, fromId)
272
+ isNativeReadResult(result) && matchesPage(result, pending, pending.toId)
238
273
  ? result
239
274
  : { error: "Recipient returned an invalid last-message page" },
240
275
  );
@@ -242,7 +277,7 @@ export class LastMessageRelay {
242
277
 
243
278
  disconnected(sessionId: string): void {
244
279
  for (const [token, pending] of this.pending) {
245
- if (pending.fromId === sessionId || pending.toId === sessionId) {
280
+ if ([pending.fromId, pending.toId, pending.via].includes(sessionId)) {
246
281
  this.finish(token, { error: "Peer disconnected during last-message read" });
247
282
  }
248
283
  }
@@ -0,0 +1,130 @@
1
+ import type net from "net";
2
+ import { randomUUID } from "crypto";
3
+ import type { Message, SessionInfo } from "../types.ts";
4
+ import { nativePeerId } from "../session-targets.ts";
5
+ import { writeMessage } from "./framing.ts";
6
+
7
+ type Retained = Array<{ readonly from: SessionInfo; readonly message: Message }>;
8
+ interface Parked {
9
+ readonly retained: Retained;
10
+ /** The fenced actor's socket closed: input waits for its restored replacement. */
11
+ closed: boolean;
12
+ }
13
+ export interface ParkablePeer {
14
+ readonly socket: net.Socket;
15
+ readonly info: SessionInfo;
16
+ /** This client can park itself, or wake and answer for its parked children. */
17
+ readonly parking: boolean;
18
+ parked?: Parked;
19
+ }
20
+ export function reachable<T extends ParkablePeer>(target?: T): target is T {
21
+ const socket = target?.socket;
22
+ return socket !== undefined && !socket.destroyed && socket.writable && !socket.writableEnded;
23
+ }
24
+ type Deliver = (id: string, from: SessionInfo, message: Message) => string | null;
25
+ function requested(id: unknown): string | undefined {
26
+ return typeof id === "string" && /^[a-zA-Z0-9_-]{8,80}$/.test(id) ? id : undefined;
27
+ }
28
+
29
+ /** A fenced child keeps its identity and queued input; it never departs while parked. */
30
+ export class ParkedSessions<T extends ParkablePeer> {
31
+ private readonly sessions: ReadonlyMap<string, T>;
32
+ private readonly deliver: Deliver;
33
+ constructor(sessions: ReadonlyMap<string, T>, deliver: Deliver) {
34
+ this.sessions = sessions;
35
+ this.deliver = deliver;
36
+ }
37
+ park(socket: net.Socket, id: string, on: boolean): void {
38
+ const session = this.sessions.get(id);
39
+ if (on && session?.parking === true) {
40
+ session.parked ??= { retained: [], closed: false };
41
+ // Frames already written to this socket precede the acknowledgement.
42
+ writeMessage(socket, { type: "parked" });
43
+ } else if (!on && session?.parked?.closed === false) {
44
+ const { retained } = session.parked;
45
+ session.parked = undefined;
46
+ this.flush(id, retained);
47
+ }
48
+ }
49
+ /**
50
+ * Delivers retained input in order. A transport that fails mid-flush keeps the rest for its
51
+ * next generation; delivery refusals on a live transport follow resident delivery.
52
+ */
53
+ private flush(id: string, retained: Retained): void {
54
+ for (const [index, { from, message }] of retained.entries()) {
55
+ const session = this.sessions.get(id);
56
+ if (this.deliver(id, from, message) !== null && session?.socket.writable === false) {
57
+ session.parked = { retained: retained.slice(index), closed: false };
58
+ return;
59
+ }
60
+ }
61
+ }
62
+ /** A free requested identity, or a parked one resumed by the same child lineage. */
63
+ claim(requestedId: unknown, next: Omit<SessionInfo, "id">): { id: string; resumed?: T } {
64
+ const id = requested(requestedId);
65
+ const previous = id === undefined ? undefined : this.sessions.get(id);
66
+ const resumes =
67
+ previous?.parked !== undefined &&
68
+ previous.info.rootSessionId === next.rootSessionId &&
69
+ previous.info.parentSessionId === next.parentSessionId;
70
+ if (id === undefined || (previous && !resumes)) {
71
+ return { id: randomUUID() };
72
+ }
73
+ previous?.socket.destroy();
74
+ return { id, resumed: previous };
75
+ }
76
+ /** Flushes a restored child's input and re-sends wakes owed to a (re)registered main. */
77
+ registered(id: string, previous?: T): void {
78
+ this.flush(id, previous?.parked?.retained ?? []);
79
+ for (const child of this.sessions.values()) {
80
+ if (nativePeerId(child.info.rootSessionId ?? "") === id) {
81
+ this.wake(child);
82
+ }
83
+ }
84
+ }
85
+ /** A transport handoff, not a departure: no session_left and queued input stays. */
86
+ closed(session: T): boolean {
87
+ const { parked } = session;
88
+ if (parked) {
89
+ parked.closed = true;
90
+ this.wake(session);
91
+ }
92
+ return parked !== undefined;
93
+ }
94
+ /**
95
+ * Exited parked children keep identities while some live session remains. Input already
96
+ * admitted for one keeps the broker until a returning main wakes it.
97
+ */
98
+ get exited(): boolean {
99
+ return [...this.sessions.values()].every(
100
+ ({ parked }) => parked?.closed === true && parked.retained.length === 0,
101
+ );
102
+ }
103
+ retain(target: T | undefined, from: SessionInfo, message: Message): boolean {
104
+ target?.parked?.retained.push({ from, message });
105
+ if (target) {
106
+ this.wake(target);
107
+ }
108
+ return target?.parked !== undefined;
109
+ }
110
+ /** Reads of an exited parked child go to its main, which answers from captured history. */
111
+ reader(id: string): T | undefined {
112
+ const child = this.sessions.get(id);
113
+ return child?.parked?.closed === true ? this.main(child) : undefined;
114
+ }
115
+ // Passive breadcrumbs and topic updates wait for a genuine wake; they never spawn the child.
116
+ private wake(child: T): void {
117
+ const main = this.main(child);
118
+ const wakes = child.parked?.retained.some(
119
+ ({ message }) =>
120
+ message.passive !== true && message.delivery !== "passive" && message.topic === undefined,
121
+ );
122
+ if (main && child.parked?.closed === true && wakes === true) {
123
+ writeMessage(main.socket, { type: "wake", sessionId: child.info.id });
124
+ }
125
+ }
126
+ private main(child: T): T | undefined {
127
+ const main = this.sessions.get(nativePeerId(child.info.rootSessionId ?? ""));
128
+ return main?.parking === true && main.parked === undefined ? main : undefined;
129
+ }
130
+ }
@@ -37,7 +37,7 @@ type ClientMessageHandler = (
37
37
  export function acceptBrokerConnection(
38
38
  socket: net.Socket,
39
39
  handleMessage: ClientMessageHandler,
40
- disconnected: (sessionId: string) => void,
40
+ disconnected: (sessionId: string, socket: net.Socket) => void,
41
41
  ): void {
42
42
  let sessionId: string | null = null;
43
43
  socket.on(
@@ -54,7 +54,7 @@ export function acceptBrokerConnection(
54
54
  );
55
55
  socket.on("close", () => {
56
56
  if (sessionId !== null) {
57
- disconnected(sessionId);
57
+ disconnected(sessionId, socket);
58
58
  }
59
59
  });
60
60
  socket.on("error", (error: Error) => {
@@ -6,7 +6,7 @@ import type { IntercomConfig } from "./config.ts";
6
6
  import type { IntercomLifecycle } from "./lifecycle.ts";
7
7
  import type { Message, SessionInfo, ReceiptQuery, ReceiptResult } from "./types.ts";
8
8
  import type { NativeReadQuery } from "./broker/last.ts";
9
- import { readLastMessage } from "./last-message.ts";
9
+ import { readLastMessage, type NativeHistory } from "./last-message.ts";
10
10
  import { asError } from "./validation.ts";
11
11
  import { RecoveryError } from "./tool-results.ts";
12
12
  import { formatTargetOptions, resolveSessionTarget, nativePeerId } from "./session-targets.ts";
@@ -24,10 +24,20 @@ interface ConnectionHooks {
24
24
  readonly incoming: (ctx: ExtensionContext, from: SessionInfo, message: Message) => Promise<void>;
25
25
  readonly peerLeft: (id: string) => void;
26
26
  readonly disconnected: (error: Readonly<Error>) => void;
27
+ /** A main answers for and wakes its exited parked children. */
28
+ readonly parkedHistory: (peer: SessionInfo) => Promise<NativeHistory | undefined>;
29
+ readonly wake: (peer: string) => void;
30
+ }
31
+ interface LastRequest {
32
+ readonly requestId: string;
33
+ readonly query: NativeReadQuery;
34
+ readonly parked?: SessionInfo;
27
35
  }
28
36
  /** Owns socket registration, coalesced connects and the reconnect/startup timers. */
29
37
  export class IntercomConnection {
30
38
  private client: IntercomClient | null = null;
39
+ /** The transport whose broker retains this actor's input while it parks. */
40
+ private parked: IntercomClient | null = null;
31
41
  private reconnectTimer: NodeJS.Timeout | null = null;
32
42
  private startupTimer: NodeJS.Timeout | null = null;
33
43
  private connecting: Promise<IntercomTransport> | null = null;
@@ -44,6 +54,22 @@ export class IntercomConnection {
44
54
  get active(): IntercomTransport | null {
45
55
  return this.client;
46
56
  }
57
+ async park(): Promise<boolean> {
58
+ const client = this.client;
59
+ this.parked = client !== null && (await client.park().catch(() => false)) ? client : null;
60
+ return this.parked !== null && this.parked === this.client;
61
+ }
62
+ get parking(): boolean {
63
+ return this.parked !== null;
64
+ }
65
+ get reconnected(): boolean {
66
+ return this.client !== null && this.client !== this.parked;
67
+ }
68
+ unpark(): Promise<boolean> {
69
+ const client = this.parked === this.client ? this.client : null;
70
+ this.parked = null;
71
+ return client?.park(false).catch(() => false) ?? Promise.resolve(false);
72
+ }
47
73
  start(ctx: ExtensionContext, generation: number): void {
48
74
  this.attempt = 0;
49
75
  this.clearTimers();
@@ -119,45 +145,67 @@ export class IntercomConnection {
119
145
  });
120
146
  }
121
147
  }
148
+ private answerLast(next: IntercomClient, request: LastRequest): void {
149
+ const ctx = this.lifecycle.live();
150
+ const id = next.sessionId;
151
+ if (this.client !== next || !ctx || id === null) {
152
+ next.respondLast(request.requestId, { error: "Intercom runtime no longer active" });
153
+ return;
154
+ }
155
+ const { parked, query } = request;
156
+ if ("messageId" in query) {
157
+ // The broker refuses receipt reads of an exited parked child before relaying them.
158
+ next.respondLast(
159
+ request.requestId,
160
+ parked ? { error: "publication_unavailable" } : this.hooks.receipt(query),
161
+ );
162
+ return;
163
+ }
164
+ if (parked) {
165
+ this.hooks
166
+ .parkedHistory(parked)
167
+ .then((history) =>
168
+ history
169
+ ? readLastMessage(history, parked, query)
170
+ : { error: "Parked session history is unavailable." },
171
+ )
172
+ .catch((error: unknown) => ({ error: asError(error).message }))
173
+ .then((result) => {
174
+ next.respondLast(request.requestId, result);
175
+ })
176
+ .catch(() => {
177
+ /* The requester's broker timeout reports a lost transport. */
178
+ });
179
+ return;
180
+ }
181
+ try {
182
+ next.respondLast(
183
+ request.requestId,
184
+ readLastMessage(
185
+ ctx.sessionManager,
186
+ {
187
+ id,
188
+ ...this.lifecycle.identity(ctx.sessionManager.getSessionId()),
189
+ cwd: ctx.cwd,
190
+ model: ctx.model?.id ?? "unknown",
191
+ status: this.lifecycle.status(),
192
+ },
193
+ query,
194
+ ),
195
+ );
196
+ } catch (error) {
197
+ next.respondLast(request.requestId, { error: asError(error).message });
198
+ }
199
+ }
122
200
  private createClient(): IntercomClient {
123
201
  const next = new IntercomClient({
124
202
  sendTimeoutMs: this.config.sendTimeoutMs,
125
203
  listTimeoutMs: this.config.listTimeoutMs,
126
204
  });
127
205
  this.client = next;
128
- next.on(
129
- "last_request",
130
- (request: { readonly requestId: string; readonly query: NativeReadQuery }) => {
131
- const ctx = this.lifecycle.live();
132
- const id = next.sessionId;
133
- if (this.client !== next || !ctx || id === null) {
134
- next.respondLast(request.requestId, { error: "Intercom runtime no longer active" });
135
- return;
136
- }
137
- try {
138
- if ("messageId" in request.query) {
139
- next.respondLast(request.requestId, this.hooks.receipt(request.query));
140
- return;
141
- }
142
- next.respondLast(
143
- request.requestId,
144
- readLastMessage(
145
- ctx.sessionManager,
146
- {
147
- id,
148
- ...this.lifecycle.identity(ctx.sessionManager.getSessionId()),
149
- cwd: ctx.cwd,
150
- model: ctx.model?.id ?? "unknown",
151
- status: this.lifecycle.status(),
152
- },
153
- request.query,
154
- ),
155
- );
156
- } catch (error) {
157
- next.respondLast(request.requestId, { error: asError(error).message });
158
- }
159
- },
160
- );
206
+ next.on("last_request", (request: LastRequest) => {
207
+ this.answerLast(next, request);
208
+ });
161
209
  next.on("message", (from: SessionInfo, message: Message) => {
162
210
  const ctx = this.lifecycle.live();
163
211
  if (this.client !== next || !ctx) {
@@ -167,6 +215,11 @@ export class IntercomConnection {
167
215
  console.error("Intercom inbound delivery failed:", error);
168
216
  });
169
217
  });
218
+ next.on("wake", (peer: string) => {
219
+ if (this.client === next) {
220
+ this.hooks.wake(peer);
221
+ }
222
+ });
170
223
  next.on("session_left", (id: string) => {
171
224
  if (this.client !== next) {
172
225
  return;