@fitchmultz/pi-subagents 0.52.4 → 0.52.5

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 (64) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +5 -4
  3. package/dist/agent/completion-watches.js +61 -0
  4. package/dist/agent/contracts.js +9 -0
  5. package/dist/agent/host.js +5 -0
  6. package/dist/agent/index.js +20 -51
  7. package/dist/agent/legacy.js +41 -11
  8. package/dist/agent/notifications.js +2 -2
  9. package/dist/agent/parking.js +121 -13
  10. package/dist/agent/process.js +107 -63
  11. package/dist/agent/receipts.js +21 -4
  12. package/dist/agent/runner.js +19 -5
  13. package/dist/agent/shutdown.js +43 -0
  14. package/dist/agent/view.js +5 -1
  15. package/dist/build-info.js +1 -1
  16. package/dist/pi-intercom/broker/broker.js +7 -6
  17. package/dist/pi-intercom/broker/client-wake.d.ts +17 -0
  18. package/dist/pi-intercom/broker/client-wake.js +54 -0
  19. package/dist/pi-intercom/broker/client.d.ts +2 -0
  20. package/dist/pi-intercom/broker/client.js +23 -16
  21. package/dist/pi-intercom/broker/parked.js +122 -5
  22. package/dist/pi-intercom/broker/spawn.js +10 -6
  23. package/dist/pi-intercom/config.js +3 -2
  24. package/dist/pi-intercom/connection.js +72 -11
  25. package/dist/pi-intercom/inbound-delivery.js +10 -5
  26. package/dist/pi-intercom/index.js +41 -6
  27. package/dist/pi-intercom/outbound.js +24 -7
  28. package/dist/pi-intercom/reply-wait.js +6 -1
  29. package/dist/pi-intercom/tool-results.js +2 -0
  30. package/dist/pi-intercom/types.d.ts +6 -0
  31. package/dist/shared/background-reporting.js +72 -0
  32. package/dist/shared/parking.js +6 -0
  33. package/docs/development.md +2 -0
  34. package/docs/intercom.md +14 -0
  35. package/docs/performance.md +2 -2
  36. package/docs/reference.md +13 -5
  37. package/package.json +1 -1
  38. package/src/agent/completion-watches.ts +73 -0
  39. package/src/agent/contracts.ts +11 -0
  40. package/src/agent/host.ts +6 -0
  41. package/src/agent/index.ts +24 -54
  42. package/src/agent/legacy.ts +44 -12
  43. package/src/agent/notifications.ts +2 -3
  44. package/src/agent/parking.ts +148 -12
  45. package/src/agent/process.ts +130 -66
  46. package/src/agent/receipts.ts +25 -4
  47. package/src/agent/runner.ts +25 -5
  48. package/src/agent/shutdown.ts +50 -0
  49. package/src/agent/view.ts +9 -0
  50. package/src/pi-intercom/broker/broker.ts +8 -7
  51. package/src/pi-intercom/broker/client-wake.ts +61 -0
  52. package/src/pi-intercom/broker/client.ts +23 -17
  53. package/src/pi-intercom/broker/parked.ts +152 -10
  54. package/src/pi-intercom/broker/spawn.ts +10 -9
  55. package/src/pi-intercom/config.ts +5 -2
  56. package/src/pi-intercom/connection.ts +88 -12
  57. package/src/pi-intercom/inbound-delivery.ts +21 -6
  58. package/src/pi-intercom/index.ts +64 -21
  59. package/src/pi-intercom/outbound.ts +30 -6
  60. package/src/pi-intercom/reply-wait.ts +6 -1
  61. package/src/pi-intercom/tool-results.ts +1 -0
  62. package/src/pi-intercom/types.ts +4 -0
  63. package/src/shared/background-reporting.ts +78 -0
  64. package/src/shared/parking.ts +16 -0
@@ -116,9 +116,7 @@ class IntercomBroker {
116
116
  if (currentId === null && msg.type !== "register") {
117
117
  throw new Error(`Received ${msg.type} before register`);
118
118
  }
119
- if (currentId !== null) {
120
- this.touchActivity(currentId, false);
121
- }
119
+ this.touchActivity(currentId, false);
122
120
  if (msg.type === "register") {
123
121
  if (currentId !== null) {
124
122
  throw new Error("Received duplicate register message");
@@ -152,7 +150,8 @@ class IntercomBroker {
152
150
  break;
153
151
  case "park":
154
152
  case "unpark":
155
- this.parking.park(socket, currentId, msg.type === "park");
153
+ case "wake_result":
154
+ this.parking.handle(socket, currentId, msg);
156
155
  break;
157
156
  default:
158
157
  throw new Error(`Unknown client message type: ${msg.type}`);
@@ -172,7 +171,7 @@ class IntercomBroker {
172
171
  topics: resumed?.topics ?? new TopicSnapshot(info),
173
172
  topicFrames,
174
173
  lastMessages: msg.lastMessages === true,
175
- parking: msg.parking === true,
174
+ ...this.parking.features(msg),
176
175
  receiptReads: msg.receiptReads === 1,
177
176
  });
178
177
  setId(id);
@@ -185,6 +184,7 @@ class IntercomBroker {
185
184
  sessionId: id,
186
185
  lastMessages: true,
187
186
  parking: msg.parking === true,
187
+ ...this.parking.capabilities(msg),
188
188
  receiptReads: 1,
189
189
  ...(msg.guardedDelivery === 1 ? { guardedDelivery: 1 } : {}),
190
190
  ...(topicFrames ? { topicsSupported: true, topicFrames: true } : {}),
@@ -397,13 +397,14 @@ class IntercomBroker {
397
397
  accepted: reason === null,
398
398
  delivered: reason === null && !queued,
399
399
  ...(queued ? { queued } : {}),
400
+ ...(queued ? { reason: this.parking.queueReason(target, message.id) } : {}),
400
401
  ...(reason !== null ? { reason } : {}),
401
402
  };
402
403
  }
403
404
 
404
405
  /** Update liveness/intercom-activity timestamps for a connected session. */
405
- private touchActivity(sessionId: string, comms: boolean): void {
406
- const session = this.sessions.get(sessionId);
406
+ private touchActivity(sessionId: string | null, comms: boolean): void {
407
+ const session = sessionId === null ? undefined : this.sessions.get(sessionId);
407
408
  if (!session) {
408
409
  return;
409
410
  }
@@ -0,0 +1,61 @@
1
+ import type net from "node:net";
2
+ import type { EventEmitter } from "node:events";
3
+ import type { WakeResult } from "../types.ts";
4
+ import type { UnknownRecord } from "../../shared/unknown.ts";
5
+ import { validateIntercomMessageSize, writeMessage } from "./framing.ts";
6
+
7
+ /** Negotiated wake outcomes; old brokers and clients keep the original wake event. */
8
+ export class WakeMessages {
9
+ supported = false;
10
+ private readonly events: EventEmitter;
11
+ private readonly socket: () => net.Socket;
12
+ constructor(events: EventEmitter, socket: () => net.Socket) {
13
+ this.events = events;
14
+ this.socket = socket;
15
+ }
16
+ registration(): { wakeResults?: 1; wakeNotices?: 1 } {
17
+ return {
18
+ ...(this.events.listenerCount("wake_request") > 0 ? { wakeResults: 1 } : {}),
19
+ ...(this.events.listenerCount("wake_failed") > 0 ? { wakeNotices: 1 } : {}),
20
+ };
21
+ }
22
+ receive(msg: UnknownRecord): void {
23
+ if (msg.type === "wake_registered") {
24
+ if (typeof msg.requestId !== "string") {
25
+ throw new Error("Invalid wake registration");
26
+ }
27
+ this.events.emit("wake_registered", msg.requestId);
28
+ return;
29
+ }
30
+ if (typeof msg.sessionId !== "string") {
31
+ throw new Error("Invalid wake message");
32
+ }
33
+ if (msg.type === "wake") {
34
+ if (this.supported && typeof msg.requestId === "string") {
35
+ this.events.emit("wake_request", msg.sessionId, msg.requestId);
36
+ } else {
37
+ this.events.emit("wake", msg.sessionId);
38
+ }
39
+ return;
40
+ }
41
+ if (typeof msg.messageId !== "string" || typeof msg.reason !== "string") {
42
+ throw new Error("Invalid wake failure");
43
+ }
44
+ this.events.emit("wake_failed", msg.sessionId, msg.messageId, msg.reason);
45
+ }
46
+ respond(requestId: string, result: WakeResult): void {
47
+ if (!this.supported) {
48
+ return;
49
+ }
50
+ const frame = { type: "wake_result", requestId, result };
51
+ writeMessage(
52
+ this.socket(),
53
+ validateIntercomMessageSize(frame) === null
54
+ ? frame
55
+ : {
56
+ ...frame,
57
+ result: { status: "failed", reason: "Wake failed; inspect the agent run for details." },
58
+ },
59
+ );
60
+ }
61
+ }
@@ -6,6 +6,7 @@ import { BrokerConnection, connectBrokerSocket } from "./connection.ts";
6
6
  import { SnapshotReader } from "./snapshot-reader.ts";
7
7
  import { LastMessageRequests, lastRequest } from "./client-last.ts";
8
8
  import { ParkRequests } from "./client-park.ts";
9
+ import { WakeMessages } from "./client-wake.ts";
9
10
  import { isNativeReadResult } from "./last.ts";
10
11
  import {
11
12
  errorMessage,
@@ -66,6 +67,7 @@ export class IntercomClient extends EventEmitter {
66
67
  private _sessionId: string | null = null;
67
68
  private _topicsSupported = false;
68
69
  private readonly parking = new ParkRequests();
70
+ readonly wakes = new WakeMessages(this, () => this.requireActiveSocket());
69
71
  private guardedDeliverySupported = false;
70
72
  private readonly lastRequests: LastMessageRequests;
71
73
  private readonly pendingSends = new Map<
@@ -158,6 +160,7 @@ export class IntercomClient extends EventEmitter {
158
160
  this._topicsSupported = false;
159
161
  this.parking.settle(false);
160
162
  this.guardedDeliverySupported = false;
163
+ this.wakes.supported = false;
161
164
  this.disconnecting = false;
162
165
  this.failPending(error);
163
166
  if (established && !expected) {
@@ -177,6 +180,7 @@ export class IntercomClient extends EventEmitter {
177
180
  parking: true,
178
181
  receiptReads: 1,
179
182
  guardedDelivery: 1,
183
+ ...this.wakes.registration(),
180
184
  });
181
185
  } catch (error) {
182
186
  if (this.connection === connection) {
@@ -213,46 +217,47 @@ export class IntercomClient extends EventEmitter {
213
217
  throw new Error("Invalid broker message");
214
218
  }
215
219
 
216
- const brokerMessage = msg;
217
- const type = msg.type;
218
-
219
- if (this._sessionId === null && type !== "registered") {
220
- throw new Error(`Received ${type} before registered`);
220
+ if (this._sessionId === null && msg.type !== "registered") {
221
+ throw new Error(`Received ${msg.type} before registered`);
221
222
  }
222
223
 
223
- switch (type) {
224
+ switch (msg.type) {
224
225
  case "registered":
225
- this.receiveRegistration(brokerMessage);
226
+ this.receiveRegistration(msg);
226
227
  break;
227
228
  case "sessions":
228
- this.receiveSessions(brokerMessage);
229
+ this.receiveSessions(msg);
229
230
  break;
230
231
  case "last_request":
231
- this.receiveLastRequest(brokerMessage);
232
+ this.receiveLastRequest(msg);
232
233
  break;
233
234
  case "last_response":
234
- this.lastRequests.receive(brokerMessage);
235
+ this.lastRequests.receive(msg);
235
236
  break;
236
237
  case "message":
237
- this.receiveMessage(brokerMessage);
238
+ this.receiveMessage(msg);
238
239
  break;
239
240
  case "delivered":
240
241
  case "delivery_queued":
241
242
  case "delivery_failed":
242
- this.receiveDelivery(brokerMessage);
243
+ this.receiveDelivery(msg);
243
244
  break;
244
245
  case "parked":
245
246
  this.parking.settle(true);
246
247
  break;
247
- case "wake":
248
248
  case "session_left":
249
- if (typeof brokerMessage.sessionId !== "string") {
250
- throw new Error(`Invalid ${type} message`);
249
+ if (typeof msg.sessionId !== "string") {
250
+ throw new Error(`Invalid ${msg.type} message`);
251
251
  }
252
- this.emit(type, brokerMessage.sessionId);
252
+ this.emit(msg.type, msg.sessionId);
253
+ break;
254
+ case "wake":
255
+ case "wake_failed":
256
+ case "wake_registered":
257
+ this.wakes.receive(msg);
253
258
  break;
254
259
  default:
255
- throw new Error(`Unknown broker message type: ${type}`);
260
+ throw new Error(`Unknown broker message type: ${msg.type}`);
256
261
  }
257
262
  }
258
263
 
@@ -269,6 +274,7 @@ export class IntercomClient extends EventEmitter {
269
274
  this.parking.supported = msg.parking === true;
270
275
  this.lastRequests.receiptReads = msg.receiptReads === 1;
271
276
  this.guardedDeliverySupported = msg.guardedDelivery === 1;
277
+ this.wakes.supported = msg.wakeResults === 1;
272
278
  this.connection?.confirmRegistration();
273
279
  this.emit("_registered", { type: "registered", sessionId: msg.sessionId });
274
280
  }
@@ -2,19 +2,35 @@ import type net from "net";
2
2
  import { randomUUID } from "crypto";
3
3
  import type { Message, SessionInfo } from "../types.ts";
4
4
  import { nativePeerId } from "../session-targets.ts";
5
- import { writeMessage } from "./framing.ts";
5
+ import { isRecord, type UnknownRecord } from "../../shared/unknown.ts";
6
+ import type { WakeResult } from "../../shared/parking.ts";
7
+ import { validateIntercomMessageSize, writeMessage } from "./framing.ts";
6
8
 
7
- type Retained = Array<{ readonly from: SessionInfo; readonly message: Message }>;
9
+ type Retained = Array<{
10
+ readonly from: SessionInfo;
11
+ readonly sender: net.Socket | undefined;
12
+ readonly message: Message;
13
+ }>;
14
+ interface WakeAttempt {
15
+ readonly id: string;
16
+ readonly owner: net.Socket;
17
+ readonly count: number;
18
+ retry: boolean;
19
+ }
8
20
  interface Parked {
9
21
  readonly retained: Retained;
10
22
  /** The fenced actor's socket closed: input waits for its restored replacement. */
11
23
  closed: boolean;
24
+ attempt?: WakeAttempt;
25
+ lastFailure?: string;
12
26
  }
13
27
  export interface ParkablePeer {
14
28
  readonly socket: net.Socket;
15
29
  readonly info: SessionInfo;
16
30
  /** This client can park itself, or wake and answer for its parked children. */
17
31
  readonly parking: boolean;
32
+ readonly wakeResults: boolean;
33
+ readonly wakeNotices: boolean;
18
34
  parked?: Parked;
19
35
  }
20
36
  export function reachable<T extends ParkablePeer>(target?: T): target is T {
@@ -25,6 +41,17 @@ type Deliver = (id: string, from: SessionInfo, message: Message) => string | nul
25
41
  function requested(id: unknown): string | undefined {
26
42
  return typeof id === "string" && /^[a-zA-Z0-9_-]{8,80}$/.test(id) ? id : undefined;
27
43
  }
44
+ function wakeResponse(msg: UnknownRecord): { id: string; result: WakeResult } {
45
+ if (typeof msg.requestId === "string" && isRecord(msg.result)) {
46
+ if (msg.result.status === "requested") {
47
+ return { id: msg.requestId, result: { status: "requested" } };
48
+ }
49
+ if (msg.result.status === "failed" && typeof msg.result.reason === "string") {
50
+ return { id: msg.requestId, result: { status: "failed", reason: msg.result.reason } };
51
+ }
52
+ }
53
+ throw new Error("Invalid wake response");
54
+ }
28
55
 
29
56
  /** A fenced child keeps its identity and queued input; it never departs while parked. */
30
57
  export class ParkedSessions<T extends ParkablePeer> {
@@ -34,7 +61,21 @@ export class ParkedSessions<T extends ParkablePeer> {
34
61
  this.sessions = sessions;
35
62
  this.deliver = deliver;
36
63
  }
37
- park(socket: net.Socket, id: string, on: boolean): void {
64
+ handle(socket: net.Socket, id: string, msg: UnknownRecord): boolean {
65
+ if (msg.type === "park" || msg.type === "unpark") {
66
+ this.park(socket, id, msg.type === "park");
67
+ return true;
68
+ }
69
+ if (msg.type === "wake_result") {
70
+ const owner = this.sessions.get(id);
71
+ if (owner) {
72
+ this.respond(owner, msg);
73
+ }
74
+ return true;
75
+ }
76
+ return false;
77
+ }
78
+ private park(socket: net.Socket, id: string, on: boolean): void {
38
79
  const session = this.sessions.get(id);
39
80
  if (on && session?.parking === true) {
40
81
  session.parked ??= { retained: [], closed: false };
@@ -75,6 +116,7 @@ export class ParkedSessions<T extends ParkablePeer> {
75
116
  }
76
117
  /** Flushes a restored child's input and re-sends wakes owed to a (re)registered main. */
77
118
  registered(id: string, previous?: T): void {
119
+ this.registeredWake(previous);
78
120
  this.flush(id, previous?.parked?.retained ?? []);
79
121
  for (const child of this.sessions.values()) {
80
122
  if (nativePeerId(child.info.rootSessionId ?? "") === id) {
@@ -82,6 +124,26 @@ export class ParkedSessions<T extends ParkablePeer> {
82
124
  }
83
125
  }
84
126
  }
127
+ private registeredWake(previous?: T): void {
128
+ const main = previous && this.main(previous);
129
+ const attempt = previous?.parked?.attempt;
130
+ if (reachable(main) && attempt?.owner === main.socket) {
131
+ writeMessage(main.socket, { type: "wake_registered", requestId: attempt.id });
132
+ }
133
+ }
134
+ capabilities(msg: UnknownRecord): { wakeResults?: 1; wakeNotices?: 1 } {
135
+ return {
136
+ ...(msg.wakeResults === 1 ? { wakeResults: 1 } : {}),
137
+ ...(msg.wakeNotices === 1 ? { wakeNotices: 1 } : {}),
138
+ };
139
+ }
140
+ features(msg: UnknownRecord): Pick<ParkablePeer, "parking" | "wakeResults" | "wakeNotices"> {
141
+ return {
142
+ parking: msg.parking === true,
143
+ wakeResults: msg.wakeResults === 1,
144
+ wakeNotices: msg.wakeNotices === 1,
145
+ };
146
+ }
85
147
  /** A transport handoff, not a departure: no session_left and queued input stays. */
86
148
  closed(session: T): boolean {
87
149
  const { parked } = session;
@@ -101,12 +163,22 @@ export class ParkedSessions<T extends ParkablePeer> {
101
163
  );
102
164
  }
103
165
  retain(target: T | undefined, from: SessionInfo, message: Message): boolean {
104
- target?.parked?.retained.push({ from, message });
105
- if (target) {
166
+ target?.parked?.retained.push({ from, message, sender: this.sessions.get(from.id)?.socket });
167
+ if (target && this.waking(message)) {
106
168
  this.wake(target);
107
169
  }
108
170
  return target?.parked !== undefined;
109
171
  }
172
+ queueReason(target: T | undefined, messageId: string): string {
173
+ const last = target?.parked?.lastFailure;
174
+ const reason =
175
+ last === undefined
176
+ ? "Queued for a parked child; delivery awaits its return."
177
+ : `Queued for a parked child; the last wake failed: ${last} The message may still arrive after a later wake.`;
178
+ return validateIntercomMessageSize({ type: "delivery_queued", messageId, reason }) === null
179
+ ? reason
180
+ : "Queued for a parked child; its last wake failed. Inspect the agent run for details.";
181
+ }
110
182
  /** Reads of an exited parked child go to its main, which answers from captured history. */
111
183
  reader(id: string): T | undefined {
112
184
  const child = this.sessions.get(id);
@@ -115,14 +187,84 @@ export class ParkedSessions<T extends ParkablePeer> {
115
187
  // Passive breadcrumbs and topic updates wait for a genuine wake; they never spawn the child.
116
188
  private wake(child: T): void {
117
189
  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) {
190
+ const parked = child.parked;
191
+ if (
192
+ !reachable(main) ||
193
+ parked?.closed !== true ||
194
+ !parked.retained.some(({ message }) => this.waking(message))
195
+ ) {
196
+ return;
197
+ }
198
+ if (!main.wakeResults) {
123
199
  writeMessage(main.socket, { type: "wake", sessionId: child.info.id });
200
+ return;
201
+ }
202
+ if (parked.attempt?.owner === main.socket) {
203
+ parked.attempt.retry = true;
204
+ return;
205
+ }
206
+ const attempt = {
207
+ id: randomUUID(),
208
+ owner: main.socket,
209
+ count: parked.retained.length,
210
+ retry: false,
211
+ };
212
+ parked.attempt = attempt;
213
+ writeMessage(main.socket, { type: "wake", sessionId: child.info.id, requestId: attempt.id });
214
+ }
215
+ private waking(message: Message): boolean {
216
+ return (
217
+ message.passive !== true && message.delivery !== "passive" && message.topic === undefined
218
+ );
219
+ }
220
+ /** Only the main sent this request can report it, and only while that child stays parked. */
221
+ private respond(owner: T, msg: UnknownRecord): void {
222
+ const { id, result } = wakeResponse(msg);
223
+ const child = [...this.sessions.values()].find(
224
+ (peer) =>
225
+ peer.parked?.attempt?.id === id &&
226
+ peer.parked.attempt.owner === owner.socket &&
227
+ this.main(peer) === owner,
228
+ );
229
+ const parked = child?.parked;
230
+ const attempt = parked?.attempt;
231
+ if (!child || !parked || !attempt) {
232
+ return;
233
+ }
234
+ parked.attempt = undefined;
235
+ if (result.status === "failed") {
236
+ parked.lastFailure = result.reason;
237
+ for (const retained of parked.retained.slice(0, attempt.count)) {
238
+ this.notify(child, retained, result.reason);
239
+ }
240
+ }
241
+ if (attempt.retry) {
242
+ this.wake(child);
124
243
  }
125
244
  }
245
+ private notify(child: T, { from, sender, message }: Retained[number], reason: string): void {
246
+ const origin = this.sessions.get(from.id);
247
+ if (
248
+ message.expectsReply !== true ||
249
+ origin?.wakeNotices !== true ||
250
+ origin.socket !== sender ||
251
+ !reachable(origin)
252
+ ) {
253
+ return;
254
+ }
255
+ const frame = {
256
+ type: "wake_failed",
257
+ sessionId: child.info.id,
258
+ messageId: message.id,
259
+ reason,
260
+ };
261
+ writeMessage(
262
+ origin.socket,
263
+ validateIntercomMessageSize(frame) === null
264
+ ? frame
265
+ : { ...frame, reason: "Wake failed; inspect the agent run for details." },
266
+ );
267
+ }
126
268
  private main(child: T): T | undefined {
127
269
  const main = this.sessions.get(nativePeerId(child.info.rootSessionId ?? ""));
128
270
  return main?.parking === true && main.parked === undefined ? main : undefined;
@@ -72,9 +72,14 @@ function toError(error: unknown): Error {
72
72
  export async function spawnBrokerIfNeeded(
73
73
  brokerCommand: string,
74
74
  brokerArgs: readonly string[],
75
+ warning: (message?: string) => void = (message) => {
76
+ if (message !== undefined) {
77
+ console.warn(message);
78
+ }
79
+ },
75
80
  ): Promise<void> {
76
81
  prepareBrokerSocketPath();
77
- await warnPreviousBroker();
82
+ warning(await previousBrokerWarning());
78
83
 
79
84
  if (await isBrokerRunning()) {
80
85
  return;
@@ -170,24 +175,20 @@ function readLivePid(pidPath: string): number | null {
170
175
  return isBrokerPidReused(pid, raw) ? null : pid;
171
176
  }
172
177
 
173
- async function warnPreviousBroker(): Promise<void> {
178
+ async function previousBrokerWarning(): Promise<string | undefined> {
174
179
  const previousSockets = getPreviousBrokerSocketPaths();
175
180
  for (const socketPath of previousSockets) {
176
181
  // Migration probes are sequential and never register or send to old peers.
177
182
  // oxlint-disable-next-line no-await-in-loop
178
183
  if (isOwnedBrokerSocket(socketPath) && (await checkOneSocket(socketPath))) {
179
- console.warn(
180
- "An older agent-directory Intercom broker is still running. Its peers are not part of shared same-user discovery until they reload with the updated extension. Existing sessions and accepted work are untouched; no legacy fallback is used.",
181
- );
182
- return;
184
+ return "An older agent-directory Intercom broker is still running. Its peers are not part of shared same-user discovery until they reload with the updated extension. Existing sessions and accepted work are untouched; no legacy fallback is used.";
183
185
  }
184
186
  }
185
187
  const previousPid = readLivePid(join(getPiAgentDir(), "intercom", "broker.pid"));
186
188
  if (previousPid !== null) {
187
- console.warn(
188
- `Older Intercom broker PID ${previousPid} may still own accepted work. Its peers need to reload with the updated extension to join shared same-user discovery. Nothing was stopped or replaced; no legacy fallback is used.`,
189
- );
189
+ return `Older Intercom broker PID ${previousPid} may still own accepted work. Its peers need to reload with the updated extension to join shared same-user discovery. Nothing was stopped or replaced; no legacy fallback is used.`;
190
190
  }
191
+ return undefined;
191
192
  }
192
193
 
193
194
  export async function stopUnhealthyBrokerBeforeSpawn(): Promise<void> {
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { getPiAgentDir } from "./agent-dir.ts";
4
4
  import { isRecord, isUnknownArray } from "./validation.ts";
5
+ import { errorMessage } from "../shared/unknown.ts";
5
6
 
6
7
  export interface IntercomConfig {
7
8
  /** Broker command used to spawn the broker process (for example, Node or Bun). */
@@ -93,7 +94,7 @@ function configuredTimeout(
93
94
  }
94
95
  return value;
95
96
  }
96
- export function loadConfig(): IntercomConfig {
97
+ export function loadConfig(report: (message: string) => void = console.error): IntercomConfig {
97
98
  const configPath = join(getPiAgentDir(), "intercom", "config.json");
98
99
  if (!existsSync(configPath)) {
99
100
  return { ...defaults };
@@ -114,7 +115,9 @@ export function loadConfig(): IntercomConfig {
114
115
  listTimeoutMs: configuredTimeout(parsed, "listTimeoutMs"),
115
116
  };
116
117
  } catch (error) {
117
- console.error(`Failed to load intercom config at ${configPath}:`, error);
118
+ report(
119
+ `Failed to load intercom config at ${configPath}: ${errorMessage(error)}. Using defaults.`,
120
+ );
118
121
  return { ...defaults };
119
122
  }
120
123
  }