@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
@@ -3,8 +3,9 @@ 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 { LastMessageRequests } from "./client-last.js";
7
- import { isNativeReadQuery, isNativeReadResult } from "./last.js";
6
+ import { LastMessageRequests, lastRequest } from "./client-last.js";
7
+ import { ParkRequests } from "./client-park.js";
8
+ import { isNativeReadResult } from "./last.js";
8
9
  import { errorMessage, isRecord, isUnknownArray, } from "../../shared/unknown.js";
9
10
  import { compactTopicMessage, normalizeMessage, normalizeSessionInfo, } from "../types.js";
10
11
  /** Default delivery-ack timeout for `send` (broker acknowledges quickly). */
@@ -30,8 +31,7 @@ export class IntercomClient extends EventEmitter {
30
31
  connection = null;
31
32
  _sessionId = null;
32
33
  _topicsSupported = false;
33
- lastMessagesSupported = false;
34
- receiptReadsSupported = false;
34
+ parking = new ParkRequests();
35
35
  guardedDeliverySupported = false;
36
36
  lastRequests;
37
37
  pendingSends = new Map();
@@ -104,8 +104,7 @@ export class IntercomClient extends EventEmitter {
104
104
  this.connection = null;
105
105
  this._sessionId = null;
106
106
  this._topicsSupported = false;
107
- this.lastMessagesSupported = false;
108
- this.receiptReadsSupported = false;
107
+ this.parking.settle(false);
109
108
  this.guardedDeliverySupported = false;
110
109
  this.disconnecting = false;
111
110
  this.failPending(error);
@@ -123,6 +122,7 @@ export class IntercomClient extends EventEmitter {
123
122
  requestedId,
124
123
  topicFrames: true,
125
124
  lastMessages: true,
125
+ parking: true,
126
126
  receiptReads: 1,
127
127
  guardedDelivery: 1,
128
128
  });
@@ -183,11 +183,15 @@ export class IntercomClient extends EventEmitter {
183
183
  case "delivery_failed":
184
184
  this.receiveDelivery(brokerMessage);
185
185
  break;
186
+ case "parked":
187
+ this.parking.settle(true);
188
+ break;
189
+ case "wake":
186
190
  case "session_left":
187
191
  if (typeof brokerMessage.sessionId !== "string") {
188
- throw new Error("Invalid session_left message");
192
+ throw new Error(`Invalid ${type} message`);
189
193
  }
190
- this.emit("session_left", brokerMessage.sessionId);
194
+ this.emit(type, brokerMessage.sessionId);
191
195
  break;
192
196
  default:
193
197
  throw new Error(`Unknown broker message type: ${type}`);
@@ -202,19 +206,17 @@ export class IntercomClient extends EventEmitter {
202
206
  }
203
207
  this._sessionId = msg.sessionId;
204
208
  this._topicsSupported = msg.topicsSupported === true && msg.topicFrames === true;
205
- this.lastMessagesSupported = msg.lastMessages === true;
206
- this.receiptReadsSupported = msg.receiptReads === 1;
209
+ this.lastRequests.lastMessages = msg.lastMessages === true;
210
+ this.parking.supported = msg.parking === true;
211
+ this.lastRequests.receiptReads = msg.receiptReads === 1;
207
212
  this.guardedDeliverySupported = msg.guardedDelivery === 1;
208
213
  this.connection?.confirmRegistration();
209
214
  this.emit("_registered", { type: "registered", sessionId: msg.sessionId });
210
215
  }
211
216
  receiveLastRequest(msg) {
212
- const from = normalizeSessionInfo(msg.from);
213
- if (typeof msg.requestId !== "string" || !from || !isNativeReadQuery(msg.query)) {
214
- throw new Error("Invalid last-message request");
215
- }
216
- if (!this.emit("last_request", { requestId: msg.requestId, from, query: msg.query })) {
217
- this.respondLast(msg.requestId, { error: "Recipient has no last-message reader." });
217
+ const request = lastRequest(msg);
218
+ if (!this.emit("last_request", request)) {
219
+ this.respondLast(request.requestId, { error: "Recipient has no last-message reader." });
218
220
  }
219
221
  }
220
222
  receiveMessage(msg) {
@@ -269,6 +271,9 @@ export class IntercomClient extends EventEmitter {
269
271
  pending.resolve(pending.reader.finish());
270
272
  }
271
273
  }
274
+ park(on = true) {
275
+ return this.parking.request(() => this.requireActiveSocket(), on);
276
+ }
272
277
  async disconnect() {
273
278
  const connection = this.connection;
274
279
  if (!connection) {
@@ -282,20 +287,10 @@ export class IntercomClient extends EventEmitter {
282
287
  return this.requestSessions().then((snapshot) => [...snapshot.sessions]);
283
288
  }
284
289
  async last(to, query = {}) {
285
- return await this.lastRequests.last(this.readSocket(query), to, query);
290
+ return await this.lastRequests.last(this.requireActiveSocket(), to, query);
286
291
  }
287
292
  async receipt(to, query) {
288
- return await this.lastRequests.receipt(this.readSocket(query), to, query);
289
- }
290
- readSocket(query) {
291
- const socket = this.requireActiveSocket();
292
- if (!this.lastMessagesSupported) {
293
- throw new Error("This broker does not support last-message reads; reload after it exits.");
294
- }
295
- if ("messageId" in query && !this.receiptReadsSupported) {
296
- throw new Error("publication_unavailable");
297
- }
298
- return socket;
293
+ return await this.lastRequests.receipt(this.requireActiveSocket(), to, query);
299
294
  }
300
295
  respondLast(requestId, result) {
301
296
  if (!isNativeReadResult(result)) {
@@ -12,13 +12,17 @@ interface LastPeer {
12
12
  readonly info: SessionInfo;
13
13
  readonly lastMessages: boolean;
14
14
  readonly receiptReads: boolean;
15
+ readonly parked?: {
16
+ readonly closed: boolean;
17
+ };
15
18
  }
16
19
  /** Correlation belongs to the broker, not the requester or the recipient's model queue. */
17
20
  export declare class LastMessageRelay {
18
21
  private readonly pending;
19
22
  private readonly sessions;
20
23
  constructor(sessions: ReadonlyMap<string, LastPeer>);
21
- request(fromId: string, message: UnknownRecord): void;
24
+ /** `reader` names another peer that answers for an exited target from its captured history. */
25
+ request(fromId: string, input: UnknownRecord, reader: (id: string) => LastPeer | undefined): void;
22
26
  respond(fromId: string, message: UnknownRecord): void;
23
27
  disconnected(sessionId: string): void;
24
28
  private finish;
@@ -63,13 +63,28 @@ export function isLastMessageResult(value) {
63
63
  return (isRecord(value) &&
64
64
  (typeof value.error === "string" || (pageIdentity(value) && pageOffsets(value))));
65
65
  }
66
- function unavailable(targets, name, pending, query) {
66
+ /** An exited parked child answers nothing itself; only its main holds the captured history. */
67
+ function exitedRefusal(target, query, via) {
68
+ if (target.parked?.closed !== true) {
69
+ return undefined;
70
+ }
71
+ // No loaded journal owner answers receipts, with or without its main.
72
+ if ("messageId" in query) {
73
+ return "publication_unavailable";
74
+ }
75
+ return via === undefined ? "Parked session history is unavailable." : undefined;
76
+ }
77
+ function unavailable(targets, name, pending, { query, via }) {
67
78
  if (targets.length === 0) {
68
79
  return "Session not found";
69
80
  }
70
81
  if (targets.length > 1) {
71
82
  return `Multiple sessions named "${name}" are connected. Use the session ID instead.`;
72
83
  }
84
+ const exited = exitedRefusal(targets[0], query, via);
85
+ if (exited !== undefined) {
86
+ return exited;
87
+ }
73
88
  if (!targets[0].lastMessages) {
74
89
  return "Recipient does not support last-message reads; reload its extension.";
75
90
  }
@@ -102,6 +117,13 @@ function matchesLastPage(result, query, fromId) {
102
117
  (query.entryId === undefined || result.entryId === query.entryId) &&
103
118
  result.text.length <= (query.limit ?? LAST_MESSAGE_PAGE_LIMIT));
104
119
  }
120
+ function lastRequest(message) {
121
+ const { requestId, to, query } = message;
122
+ if (typeof requestId !== "string" || typeof to !== "string" || !isNativeReadQuery(query)) {
123
+ throw new Error("Invalid last message request");
124
+ }
125
+ return { requestId, to, query };
126
+ }
105
127
  /** Correlation belongs to the broker, not the requester or the recipient's model queue. */
106
128
  export class LastMessageRelay {
107
129
  pending = new Map();
@@ -109,12 +131,9 @@ export class LastMessageRelay {
109
131
  constructor(sessions) {
110
132
  this.sessions = sessions;
111
133
  }
112
- request(fromId, message) {
113
- if (typeof message.requestId !== "string" ||
114
- typeof message.to !== "string" ||
115
- !isNativeReadQuery(message.query)) {
116
- throw new Error("Invalid last message request");
117
- }
134
+ /** `reader` names another peer that answers for an exited target from its captured history. */
135
+ request(fromId, input, reader) {
136
+ const message = lastRequest(input);
118
137
  const targets = findSessions(this.sessions, message.to, fromId);
119
138
  const target = targets[0];
120
139
  const from = this.sessions.get(fromId);
@@ -122,13 +141,15 @@ export class LastMessageRelay {
122
141
  return;
123
142
  }
124
143
  const token = randomUUID();
144
+ const via = targets.length === 1 ? reader(target.info.id) : undefined;
125
145
  const frame = {
126
146
  type: "last_request",
127
147
  requestId: token,
128
148
  from: messageSender(from.info),
129
149
  query: message.query,
150
+ parked: via && messageSender(target.info),
130
151
  };
131
- const error = unavailable(targets, message.to, this.pending.size, message.query) ??
152
+ const error = unavailable(targets, message.to, this.pending.size, { query: message.query, via }) ??
132
153
  validateIntercomMessageSize(frame)?.message;
133
154
  if (error !== undefined) {
134
155
  writeMessage(from.socket, {
@@ -143,12 +164,13 @@ export class LastMessageRelay {
143
164
  this.pending.set(token, {
144
165
  fromId,
145
166
  toId: target.info.id,
167
+ via: via?.info.id,
146
168
  requestId: message.requestId,
147
169
  query: message.query,
148
170
  timer,
149
171
  });
150
172
  try {
151
- writeMessage(target.socket, frame);
173
+ writeMessage((via ?? target).socket, frame);
152
174
  }
153
175
  catch {
154
176
  this.finish(token, { error: "Recipient disconnected before last-message read" });
@@ -159,17 +181,17 @@ export class LastMessageRelay {
159
181
  throw new Error("Invalid last message response");
160
182
  }
161
183
  const pending = this.pending.get(message.requestId);
162
- if (!pending || pending.toId !== fromId) {
184
+ if (!pending || (pending.via ?? pending.toId) !== fromId) {
163
185
  return;
164
186
  }
165
187
  const result = message.result;
166
- this.finish(message.requestId, isNativeReadResult(result) && matchesPage(result, pending, fromId)
188
+ this.finish(message.requestId, isNativeReadResult(result) && matchesPage(result, pending, pending.toId)
167
189
  ? result
168
190
  : { error: "Recipient returned an invalid last-message page" });
169
191
  }
170
192
  disconnected(sessionId) {
171
193
  for (const [token, pending] of this.pending) {
172
- if (pending.fromId === sessionId || pending.toId === sessionId) {
194
+ if ([pending.fromId, pending.toId, pending.via].includes(sessionId)) {
173
195
  this.finish(token, { error: "Peer disconnected during last-message read" });
174
196
  }
175
197
  }
@@ -0,0 +1,107 @@
1
+ import { randomUUID } from "crypto";
2
+ import { nativePeerId } from "../session-targets.js";
3
+ import { writeMessage } from "./framing.js";
4
+ export function reachable(target) {
5
+ const socket = target?.socket;
6
+ return socket !== undefined && !socket.destroyed && socket.writable && !socket.writableEnded;
7
+ }
8
+ function requested(id) {
9
+ return typeof id === "string" && /^[a-zA-Z0-9_-]{8,80}$/.test(id) ? id : undefined;
10
+ }
11
+ /** A fenced child keeps its identity and queued input; it never departs while parked. */
12
+ export class ParkedSessions {
13
+ sessions;
14
+ deliver;
15
+ constructor(sessions, deliver) {
16
+ this.sessions = sessions;
17
+ this.deliver = deliver;
18
+ }
19
+ park(socket, id, on) {
20
+ const session = this.sessions.get(id);
21
+ if (on && session?.parking === true) {
22
+ session.parked ??= { retained: [], closed: false };
23
+ // Frames already written to this socket precede the acknowledgement.
24
+ writeMessage(socket, { type: "parked" });
25
+ }
26
+ else if (!on && session?.parked?.closed === false) {
27
+ const { retained } = session.parked;
28
+ session.parked = undefined;
29
+ this.flush(id, retained);
30
+ }
31
+ }
32
+ /**
33
+ * Delivers retained input in order. A transport that fails mid-flush keeps the rest for its
34
+ * next generation; delivery refusals on a live transport follow resident delivery.
35
+ */
36
+ flush(id, retained) {
37
+ for (const [index, { from, message }] of retained.entries()) {
38
+ const session = this.sessions.get(id);
39
+ if (this.deliver(id, from, message) !== null && session?.socket.writable === false) {
40
+ session.parked = { retained: retained.slice(index), closed: false };
41
+ return;
42
+ }
43
+ }
44
+ }
45
+ /** A free requested identity, or a parked one resumed by the same child lineage. */
46
+ claim(requestedId, next) {
47
+ const id = requested(requestedId);
48
+ const previous = id === undefined ? undefined : this.sessions.get(id);
49
+ const resumes = previous?.parked !== undefined &&
50
+ previous.info.rootSessionId === next.rootSessionId &&
51
+ previous.info.parentSessionId === next.parentSessionId;
52
+ if (id === undefined || (previous && !resumes)) {
53
+ return { id: randomUUID() };
54
+ }
55
+ previous?.socket.destroy();
56
+ return { id, resumed: previous };
57
+ }
58
+ /** Flushes a restored child's input and re-sends wakes owed to a (re)registered main. */
59
+ registered(id, previous) {
60
+ this.flush(id, previous?.parked?.retained ?? []);
61
+ for (const child of this.sessions.values()) {
62
+ if (nativePeerId(child.info.rootSessionId ?? "") === id) {
63
+ this.wake(child);
64
+ }
65
+ }
66
+ }
67
+ /** A transport handoff, not a departure: no session_left and queued input stays. */
68
+ closed(session) {
69
+ const { parked } = session;
70
+ if (parked) {
71
+ parked.closed = true;
72
+ this.wake(session);
73
+ }
74
+ return parked !== undefined;
75
+ }
76
+ /**
77
+ * Exited parked children keep identities while some live session remains. Input already
78
+ * admitted for one keeps the broker until a returning main wakes it.
79
+ */
80
+ get exited() {
81
+ return [...this.sessions.values()].every(({ parked }) => parked?.closed === true && parked.retained.length === 0);
82
+ }
83
+ retain(target, from, message) {
84
+ target?.parked?.retained.push({ from, message });
85
+ if (target) {
86
+ this.wake(target);
87
+ }
88
+ return target?.parked !== undefined;
89
+ }
90
+ /** Reads of an exited parked child go to its main, which answers from captured history. */
91
+ reader(id) {
92
+ const child = this.sessions.get(id);
93
+ return child?.parked?.closed === true ? this.main(child) : undefined;
94
+ }
95
+ // Passive breadcrumbs and topic updates wait for a genuine wake; they never spawn the child.
96
+ wake(child) {
97
+ const main = this.main(child);
98
+ const wakes = child.parked?.retained.some(({ message }) => message.passive !== true && message.delivery !== "passive" && message.topic === undefined);
99
+ if (main && child.parked?.closed === true && wakes === true) {
100
+ writeMessage(main.socket, { type: "wake", sessionId: child.info.id });
101
+ }
102
+ }
103
+ main(child) {
104
+ const main = this.sessions.get(nativePeerId(child.info.rootSessionId ?? ""));
105
+ return main?.parking === true && main.parked === undefined ? main : undefined;
106
+ }
107
+ }
@@ -28,7 +28,7 @@ export function acceptBrokerConnection(socket, handleMessage, disconnected) {
28
28
  }));
29
29
  socket.on("close", () => {
30
30
  if (sessionId !== null) {
31
- disconnected(sessionId);
31
+ disconnected(sessionId, socket);
32
32
  }
33
33
  });
34
34
  socket.on("error", (error) => {
@@ -7,6 +7,8 @@ import { formatTargetOptions, resolveSessionTarget, nativePeerId } from "./sessi
7
7
  /** Owns socket registration, coalesced connects and the reconnect/startup timers. */
8
8
  export class IntercomConnection {
9
9
  client = null;
10
+ /** The transport whose broker retains this actor's input while it parks. */
11
+ parked = null;
10
12
  reconnectTimer = null;
11
13
  startupTimer = null;
12
14
  connecting = null;
@@ -23,6 +25,22 @@ export class IntercomConnection {
23
25
  get active() {
24
26
  return this.client;
25
27
  }
28
+ async park() {
29
+ const client = this.client;
30
+ this.parked = client !== null && (await client.park().catch(() => false)) ? client : null;
31
+ return this.parked !== null && this.parked === this.client;
32
+ }
33
+ get parking() {
34
+ return this.parked !== null;
35
+ }
36
+ get reconnected() {
37
+ return this.client !== null && this.client !== this.parked;
38
+ }
39
+ unpark() {
40
+ const client = this.parked === this.client ? this.client : null;
41
+ this.parked = null;
42
+ return client?.park(false).catch(() => false) ?? Promise.resolve(false);
43
+ }
26
44
  start(ctx, generation) {
27
45
  this.attempt = 0;
28
46
  this.clearTimers();
@@ -98,6 +116,47 @@ export class IntercomConnection {
98
116
  });
99
117
  }
100
118
  }
119
+ answerLast(next, request) {
120
+ const ctx = this.lifecycle.live();
121
+ const id = next.sessionId;
122
+ if (this.client !== next || !ctx || id === null) {
123
+ next.respondLast(request.requestId, { error: "Intercom runtime no longer active" });
124
+ return;
125
+ }
126
+ const { parked, query } = request;
127
+ if ("messageId" in query) {
128
+ // The broker refuses receipt reads of an exited parked child before relaying them.
129
+ next.respondLast(request.requestId, parked ? { error: "publication_unavailable" } : this.hooks.receipt(query));
130
+ return;
131
+ }
132
+ if (parked) {
133
+ this.hooks
134
+ .parkedHistory(parked)
135
+ .then((history) => history
136
+ ? readLastMessage(history, parked, query)
137
+ : { error: "Parked session history is unavailable." })
138
+ .catch((error) => ({ error: asError(error).message }))
139
+ .then((result) => {
140
+ next.respondLast(request.requestId, result);
141
+ })
142
+ .catch(() => {
143
+ /* The requester's broker timeout reports a lost transport. */
144
+ });
145
+ return;
146
+ }
147
+ try {
148
+ next.respondLast(request.requestId, readLastMessage(ctx.sessionManager, {
149
+ id,
150
+ ...this.lifecycle.identity(ctx.sessionManager.getSessionId()),
151
+ cwd: ctx.cwd,
152
+ model: ctx.model?.id ?? "unknown",
153
+ status: this.lifecycle.status(),
154
+ }, query));
155
+ }
156
+ catch (error) {
157
+ next.respondLast(request.requestId, { error: asError(error).message });
158
+ }
159
+ }
101
160
  createClient() {
102
161
  const next = new IntercomClient({
103
162
  sendTimeoutMs: this.config.sendTimeoutMs,
@@ -105,28 +164,7 @@ export class IntercomConnection {
105
164
  });
106
165
  this.client = next;
107
166
  next.on("last_request", (request) => {
108
- const ctx = this.lifecycle.live();
109
- const id = next.sessionId;
110
- if (this.client !== next || !ctx || id === null) {
111
- next.respondLast(request.requestId, { error: "Intercom runtime no longer active" });
112
- return;
113
- }
114
- try {
115
- if ("messageId" in request.query) {
116
- next.respondLast(request.requestId, this.hooks.receipt(request.query));
117
- return;
118
- }
119
- next.respondLast(request.requestId, readLastMessage(ctx.sessionManager, {
120
- id,
121
- ...this.lifecycle.identity(ctx.sessionManager.getSessionId()),
122
- cwd: ctx.cwd,
123
- model: ctx.model?.id ?? "unknown",
124
- status: this.lifecycle.status(),
125
- }, request.query));
126
- }
127
- catch (error) {
128
- next.respondLast(request.requestId, { error: asError(error).message });
129
- }
167
+ this.answerLast(next, request);
130
168
  });
131
169
  next.on("message", (from, message) => {
132
170
  const ctx = this.lifecycle.live();
@@ -137,6 +175,11 @@ export class IntercomConnection {
137
175
  console.error("Intercom inbound delivery failed:", error);
138
176
  });
139
177
  });
178
+ next.on("wake", (peer) => {
179
+ if (this.client === next) {
180
+ this.hooks.wake(peer);
181
+ }
182
+ });
140
183
  next.on("session_left", (id) => {
141
184
  if (this.client !== next) {
142
185
  return;
@@ -70,7 +70,18 @@ export class IntercomInbound {
70
70
  }
71
71
  this.owners.delivery.queue(entry, "passive");
72
72
  }
73
+ /** Counted synchronously so a parking fence observes frames received before its ack. */
74
+ receiving = 0;
73
75
  async receive(ctx, from, input) {
76
+ this.receiving++;
77
+ try {
78
+ await this.receiveCounted(ctx, from, input);
79
+ }
80
+ finally {
81
+ this.receiving--;
82
+ }
83
+ }
84
+ async receiveCounted(ctx, from, input) {
74
85
  const generation = this.lifecycle.generation;
75
86
  const restore = this.owners.delivery.restoring;
76
87
  if (restore) {
@@ -12,6 +12,18 @@ import { IntercomInspection } from "./inspection.js";
12
12
  import { IntercomTools } from "./tools.js";
13
13
  import { IntercomSessionEvents } from "./session-events.js";
14
14
  import { registerIntercomTools } from "./tool-registration.js";
15
+ import { isParkRequest, PARK, PARKED_HISTORY, WAKE } from "../shared/parking.js";
16
+ /** The agent runtime answers for its exited parked child from the captured native session. */
17
+ function parkedHistory(pi, peer) {
18
+ let history;
19
+ pi.events.emit(PARKED_HISTORY, {
20
+ peer,
21
+ reply: (found) => {
22
+ history = found;
23
+ },
24
+ });
25
+ return history ?? Promise.reject(new Error("No agent runtime owns parked history."));
26
+ }
15
27
  export default function piIntercomExtension(pi) {
16
28
  const config = loadConfig();
17
29
  const lifecycle = new IntercomLifecycle(pi, config, readIntercomLineage());
@@ -30,6 +42,10 @@ export default function piIntercomExtension(pi) {
30
42
  disconnected: (error) => {
31
43
  wait.reject(new Error(`Disconnected while waiting for reply: ${error.message}`, { cause: error }));
32
44
  },
45
+ parkedHistory: (peer) => parkedHistory(pi, peer.id),
46
+ wake: (peer) => {
47
+ pi.events.emit(WAKE, peer);
48
+ },
33
49
  });
34
50
  const delivery = new InboundDeliveryOwner(pi, lifecycle, journal, {
35
51
  replies,
@@ -55,4 +71,26 @@ export default function piIntercomExtension(pi) {
55
71
  wait,
56
72
  }).register();
57
73
  registerIntercomTools(pi, tools);
74
+ pi.events.on(PARK, (request) => {
75
+ if (isParkRequest(request)) {
76
+ request.reply(request.park ? connection.park() : connection.unpark());
77
+ }
78
+ });
79
+ // Native fork sleep readiness; it vetoes only this package's own parking, never other saves.
80
+ Reflect.apply(pi.on.bind(pi), undefined, [
81
+ "working_session_save",
82
+ () => !connection.parking
83
+ ? undefined
84
+ : {
85
+ blockers: [
86
+ inbound.receiving > 0 && "intercom is receiving a message",
87
+ journal.size > 0 && "intercom inbound messages await delivery",
88
+ replies.listPending().length > 0 && "intercom asks await replies",
89
+ wait.waiting && "intercom is waiting for a reply",
90
+ (delivery.restoring !== undefined || delivery.batching) &&
91
+ "intercom delivery is restoring",
92
+ connection.reconnected && "intercom reconnected while parking",
93
+ ].filter((blocker) => typeof blocker === "string"),
94
+ },
95
+ ]);
58
96
  }
@@ -0,0 +1,12 @@
1
+ import { isRecord } from "./unknown.js";
2
+ /** A child actor asks its intercom to fence broker input before parking, or to release it. */
3
+ export const PARK = "pi-subagents:park";
4
+ /** A main intercom wakes or reads an exited parked child through the agent runtime. */
5
+ export const WAKE = "pi-subagents:wake";
6
+ export const PARKED_HISTORY = "pi-subagents:parked-history";
7
+ export function isParkRequest(value) {
8
+ return isRecord(value) && typeof value.park === "boolean" && typeof value.reply === "function";
9
+ }
10
+ export function isHistoryRequest(value) {
11
+ return isRecord(value) && typeof value.peer === "string" && typeof value.reply === "function";
12
+ }
@@ -3,6 +3,8 @@ import { readFileSync, readlinkSync } from "node:fs";
3
3
  import { isLinuxProcessReplaced, readLinuxProcess } from "../pi-intercom/broker/pid.js";
4
4
  import { hasErrorCode } from "./unknown.js";
5
5
  const BIRTH_FIELDS = ["lstart=", "pgid="];
6
+ // macOS ships /bin/ps; resolving "ps" through PATH can spawn once per missing entry.
7
+ export const PS_COMMAND = process.platform === "darwin" ? "/bin/ps" : "ps";
6
8
  /** Capture only a real, cross-process clock context; unavailable context proves nothing. */
7
9
  export function readProcessClock() {
8
10
  if (process.platform !== "android") {
@@ -45,7 +47,7 @@ function psOptions(zone) {
45
47
  }
46
48
  function readPsFields(pid, fields, zone) {
47
49
  try {
48
- return execFileSync("ps", psArguments(pid, fields), {
50
+ return execFileSync(PS_COMMAND, psArguments(pid, fields), {
49
51
  ...psOptions(zone),
50
52
  stdio: ["ignore", "pipe", "ignore"],
51
53
  })
@@ -59,7 +61,7 @@ function readPsFields(pid, fields, zone) {
59
61
  /** Nonblocking `readPsFields`; a failed run likewise observes nothing. */
60
62
  function observePsFields(pid, fields, zone) {
61
63
  return new Promise((resolve) => {
62
- execFile("ps", psArguments(pid, fields), psOptions(zone), (error, stdout) => {
64
+ execFile(PS_COMMAND, psArguments(pid, fields), psOptions(zone), (error, stdout) => {
63
65
  resolve(error ? undefined : stdout.trim().replace(/\s+/g, " "));
64
66
  });
65
67
  });
@@ -34,8 +34,8 @@ tests. CI separately qualifies the latest official Pi on GitHub-hosted Linux and
34
34
  the maintained fork on Linux and macOS. There is no custom checker build, probe
35
35
  suite or self-hosted runner requirement.
36
36
 
37
- CI's lint job also enforces the production line/byte shrink budget before
38
- qualification substitutes the selected host graph. The retired workflow schema
39
- generator and its patching dependency are not part of the new runtime.
40
- The active-child viewer has an approved aggregate budget of 19,000 source lines
41
- and 22,500 production lines. The 1 MiB byte ceiling and all lint limits stay unchanged.
37
+ CI's lint job also enforces the production byte ceiling before qualification
38
+ substitutes the selected host graph. The retired workflow schema generator and
39
+ its patching dependency are not part of the new runtime. `npm run check:size`
40
+ still reports source and production line counts for visibility; no line count
41
+ is mandated. The 1 MiB byte ceiling and all lint limits stay unchanged.
@@ -91,9 +91,9 @@ negative assertions. A sleep does not prove an owner processed its input.
91
91
 
92
92
  `scripts/measure-size.mjs` counts source, production scripts, generated code and
93
93
  schemas, including new untracked files. `--report` prints without enforcing.
94
- The ceilings are 19,000 source TypeScript lines, 22,500 total production lines,
95
- and 1 MiB total production bytes. Tests have no deletion quota. Moving machinery
96
- into generated/minified output does not evade the gate.
94
+ The only enforced ceiling is 1 MiB total production bytes; source and production
95
+ line counts are reported for visibility, not mandated. Tests have no deletion
96
+ quota. Moving machinery into generated/minified output does not evade the gate.
97
97
 
98
98
  [The rebuild evidence](single-agent-rebuild.md) records the baseline, confirmed
99
99
  design, test-transfer obligations, and exact selected host identities. Update it
package/docs/intercom.md CHANGED
@@ -342,7 +342,8 @@ the same runtime-root policy.
342
342
 
343
343
  The runtime directory is owned by the OS user, nonsymlink, and mode 0700; the
344
344
  socket is mode 0600. The broker starts on first use and exits after five seconds
345
- without connected sessions. Concurrent startup uses the shared spawn lock.
345
+ without connected sessions, unless a parked child still holds admitted input; that
346
+ input waits for its main to return and wake the child. Concurrent startup uses the shared spawn lock.
346
347
 
347
348
  `PI_INTERCOM_RUNTIME_DIR` explicitly selects an isolated deployment. It must be
348
349
  an absolute private directory with a socket path within 100 UTF-8 bytes. Invalid