@fitchmultz/pi-subagents 0.52.3 → 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 (66) hide show
  1. package/CHANGELOG.md +17 -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/bridge-session.js +16 -3
  17. package/dist/pi-intercom/broker/broker.js +7 -6
  18. package/dist/pi-intercom/broker/client-wake.d.ts +17 -0
  19. package/dist/pi-intercom/broker/client-wake.js +54 -0
  20. package/dist/pi-intercom/broker/client.d.ts +2 -0
  21. package/dist/pi-intercom/broker/client.js +23 -16
  22. package/dist/pi-intercom/broker/parked.js +122 -5
  23. package/dist/pi-intercom/broker/spawn.js +10 -6
  24. package/dist/pi-intercom/config.js +3 -2
  25. package/dist/pi-intercom/connection.js +72 -11
  26. package/dist/pi-intercom/inbound-delivery.js +10 -5
  27. package/dist/pi-intercom/index.js +41 -6
  28. package/dist/pi-intercom/outbound.js +24 -7
  29. package/dist/pi-intercom/reply-wait.js +6 -1
  30. package/dist/pi-intercom/tool-results.js +2 -0
  31. package/dist/pi-intercom/types.d.ts +6 -0
  32. package/dist/shared/background-reporting.js +72 -0
  33. package/dist/shared/parking.js +6 -0
  34. package/docs/development.md +2 -0
  35. package/docs/intercom.md +14 -0
  36. package/docs/performance.md +2 -2
  37. package/docs/reference.md +13 -5
  38. package/package.json +1 -1
  39. package/src/agent/completion-watches.ts +73 -0
  40. package/src/agent/contracts.ts +11 -0
  41. package/src/agent/host.ts +6 -0
  42. package/src/agent/index.ts +24 -54
  43. package/src/agent/legacy.ts +44 -12
  44. package/src/agent/notifications.ts +2 -3
  45. package/src/agent/parking.ts +148 -12
  46. package/src/agent/process.ts +130 -66
  47. package/src/agent/receipts.ts +25 -4
  48. package/src/agent/runner.ts +25 -5
  49. package/src/agent/shutdown.ts +50 -0
  50. package/src/agent/view.ts +9 -0
  51. package/src/pi-intercom/bridge-session.ts +14 -9
  52. package/src/pi-intercom/broker/broker.ts +8 -7
  53. package/src/pi-intercom/broker/client-wake.ts +61 -0
  54. package/src/pi-intercom/broker/client.ts +23 -17
  55. package/src/pi-intercom/broker/parked.ts +152 -10
  56. package/src/pi-intercom/broker/spawn.ts +10 -9
  57. package/src/pi-intercom/config.ts +5 -2
  58. package/src/pi-intercom/connection.ts +88 -12
  59. package/src/pi-intercom/inbound-delivery.ts +21 -6
  60. package/src/pi-intercom/index.ts +64 -21
  61. package/src/pi-intercom/outbound.ts +30 -6
  62. package/src/pi-intercom/reply-wait.ts +6 -1
  63. package/src/pi-intercom/tool-results.ts +1 -0
  64. package/src/pi-intercom/types.ts +4 -0
  65. package/src/shared/background-reporting.ts +78 -0
  66. package/src/shared/parking.ts +16 -0
@@ -10,6 +10,8 @@ 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";
13
+ import type { WakeResult } from "../shared/parking.ts";
14
+ import type { BackgroundReporter } from "../shared/background-reporting.ts";
13
15
 
14
16
  type Lifecycle = Pick<
15
17
  Readonly<IntercomLifecycle>,
@@ -26,7 +28,8 @@ interface ConnectionHooks {
26
28
  readonly disconnected: (error: Readonly<Error>) => void;
27
29
  /** A main answers for and wakes its exited parked children. */
28
30
  readonly parkedHistory: (peer: SessionInfo) => Promise<NativeHistory | undefined>;
29
- readonly wake: (peer: string) => void;
31
+ readonly wake: (peer: string, signal?: AbortSignal) => Promise<WakeResult>;
32
+ readonly wakeFailed: (peer: string, messageId: string, reason: string) => void;
30
33
  }
31
34
  interface LastRequest {
32
35
  readonly requestId: string;
@@ -46,10 +49,18 @@ export class IntercomConnection {
46
49
  private readonly lifecycle: Lifecycle;
47
50
  private readonly config: IntercomConfig;
48
51
  private readonly hooks: ConnectionHooks;
49
- constructor(lifecycle: Lifecycle, config: IntercomConfig, hooks: ConnectionHooks) {
52
+ private readonly wakes = new Map<string, AbortController>();
53
+ private readonly reporter: BackgroundReporter;
54
+ constructor(
55
+ lifecycle: Lifecycle,
56
+ config: IntercomConfig,
57
+ hooks: ConnectionHooks,
58
+ reporter: BackgroundReporter,
59
+ ) {
50
60
  this.lifecycle = lifecycle;
51
61
  this.config = config;
52
62
  this.hooks = hooks;
63
+ this.reporter = reporter;
53
64
  }
54
65
  get active(): IntercomTransport | null {
55
66
  return this.client;
@@ -197,6 +208,22 @@ export class IntercomConnection {
197
208
  next.respondLast(request.requestId, { error: asError(error).message });
198
209
  }
199
210
  }
211
+ private receive(next: IntercomClient, from: SessionInfo, message: Message): void {
212
+ const ctx = this.lifecycle.live();
213
+ if (this.client !== next || !ctx) {
214
+ return;
215
+ }
216
+ const owner = `inbound:${from.id}:${message.id}`;
217
+ this.hooks
218
+ .incoming(ctx, from, message)
219
+ .then(() => this.reporter.recover(owner))
220
+ .catch((error: unknown) =>
221
+ this.reporter.report(
222
+ owner,
223
+ `Intercom inbound ${message.id} from ${from.id} failed: ${asError(error).message}`,
224
+ ),
225
+ );
226
+ }
200
227
  private createClient(): IntercomClient {
201
228
  const next = new IntercomClient({
202
229
  sendTimeoutMs: this.config.sendTimeoutMs,
@@ -207,17 +234,48 @@ export class IntercomConnection {
207
234
  this.answerLast(next, request);
208
235
  });
209
236
  next.on("message", (from: SessionInfo, message: Message) => {
210
- const ctx = this.lifecycle.live();
211
- if (this.client !== next || !ctx) {
212
- return;
213
- }
214
- this.hooks.incoming(ctx, from, message).catch((error: unknown) => {
215
- console.error("Intercom inbound delivery failed:", error);
216
- });
237
+ this.receive(next, from, message);
217
238
  });
218
239
  next.on("wake", (peer: string) => {
219
240
  if (this.client === next) {
220
- this.hooks.wake(peer);
241
+ void this.hooks.wake(peer).catch(() => {
242
+ // Old brokers cannot receive wake outcomes.
243
+ });
244
+ }
245
+ });
246
+ next.on("wake_request", (peer: string, requestId: string) => {
247
+ if (this.client === next) {
248
+ const registered = new AbortController();
249
+ this.wakes.set(requestId, registered);
250
+ void this.hooks
251
+ .wake(peer, registered.signal)
252
+ .catch((error: unknown): WakeResult => ({
253
+ status: "failed",
254
+ reason: asError(error).message,
255
+ }))
256
+ .then((result) => {
257
+ if (this.client === next && !registered.signal.aborted) {
258
+ next.wakes.respond(requestId, result);
259
+ }
260
+ })
261
+ .finally(() => {
262
+ if (this.wakes.get(requestId) === registered) {
263
+ this.wakes.delete(requestId);
264
+ }
265
+ })
266
+ .catch(() => {
267
+ // A lost broker transport retains the input for the next registration.
268
+ });
269
+ }
270
+ });
271
+ next.on("wake_registered", (requestId: string) => {
272
+ if (this.client === next) {
273
+ this.wakes.get(requestId)?.abort();
274
+ }
275
+ });
276
+ next.on("wake_failed", (peer: string, messageId: string, reason: string) => {
277
+ if (this.client === next) {
278
+ this.hooks.wakeFailed(peer, messageId, reason);
221
279
  }
222
280
  });
223
281
  next.on("session_left", (id: string) => {
@@ -231,6 +289,7 @@ export class IntercomConnection {
231
289
  return;
232
290
  }
233
291
  this.hooks.disconnected(error);
292
+ this.cancelWakes();
234
293
  this.client = null;
235
294
  this.lifecycle.transport(null);
236
295
  this.clearReconnect();
@@ -248,7 +307,13 @@ export class IntercomConnection {
248
307
  ): Promise<IntercomTransport> {
249
308
  const next = this.createClient();
250
309
  try {
251
- await spawnBrokerIfNeeded(this.config.brokerCommand, this.config.brokerArgs);
310
+ await spawnBrokerIfNeeded(this.config.brokerCommand, this.config.brokerArgs, (message) => {
311
+ if (message === undefined) {
312
+ this.reporter.recover("older-broker");
313
+ } else {
314
+ this.reporter.report("older-broker", message, "warning");
315
+ }
316
+ });
252
317
  await next.connect(
253
318
  await this.lifecycle.registration(this.hooks.pendingAsks()),
254
319
  nativePeerId(sessionId),
@@ -261,6 +326,7 @@ export class IntercomConnection {
261
326
  this.lifecycle.transport(next.sessionId, next.supportsGuardedDelivery);
262
327
  this.syncIdentity(ctx.sessionManager.getSessionId());
263
328
  this.attempt = 0;
329
+ this.reporter.recover("connection-cleanup");
264
330
  return next;
265
331
  } catch (error) {
266
332
  if (this.client === next) {
@@ -268,7 +334,10 @@ export class IntercomConnection {
268
334
  }
269
335
  // A failed registration still owns its socket.
270
336
  await next.disconnect().catch((cleanupError: unknown) => {
271
- console.error("Intercom failed connection cleanup:", cleanupError);
337
+ this.reporter.report(
338
+ "connection-cleanup",
339
+ `Intercom failed connection cleanup: ${asError(cleanupError).message}`,
340
+ );
272
341
  });
273
342
  throw asError(error);
274
343
  }
@@ -323,6 +392,7 @@ export class IntercomConnection {
323
392
  }
324
393
  async shutdown(): Promise<void> {
325
394
  this.clearTimers();
395
+ this.cancelWakes();
326
396
  // The bounded launcher attempt is joined before socket shutdown.
327
397
  await Promise.allSettled([this.connecting]);
328
398
  if (this.client) {
@@ -330,6 +400,12 @@ export class IntercomConnection {
330
400
  this.client = null;
331
401
  }
332
402
  }
403
+ private cancelWakes(): void {
404
+ for (const wake of this.wakes.values()) {
405
+ wake.abort();
406
+ }
407
+ this.wakes.clear();
408
+ }
333
409
  }
334
410
  export async function resolveConnectedTarget(
335
411
  active: IntercomTransport,
@@ -9,6 +9,8 @@ import type {
9
9
  } from "./runtime-types.ts";
10
10
  import { requestedDelivery } from "./inbound-record.ts";
11
11
  import { runCooperatively } from "../shared/cooperative.ts";
12
+ import { asError } from "./validation.ts";
13
+ import type { BackgroundReporter } from "../shared/background-reporting.ts";
12
14
 
13
15
  type Lifecycle = Pick<Readonly<IntercomLifecycle>, "generation" | "isIdle" | "live" | "toolCount">;
14
16
  type Journal = Readonly<
@@ -33,6 +35,7 @@ type Replies = Readonly<Pick<ReplyTracker, "queueTurnContext" | "reset">>;
33
35
  interface DeliveryHooks {
34
36
  readonly replies: Replies;
35
37
  readonly syncStatus: () => void;
38
+ readonly reporter: BackgroundReporter;
36
39
  }
37
40
  /** Owns cooperative inbound batches, restoration and the flush timer. */
38
41
  export class InboundDeliveryOwner {
@@ -108,9 +111,12 @@ export class InboundDeliveryOwner {
108
111
  this.clearTimer();
109
112
  this.timer = setTimeout(() => {
110
113
  this.timer = null;
111
- this.flush(generation)?.catch((error: unknown) => {
112
- console.error("Intercom inbound delivery failed:", error);
113
- });
114
+ this.flush(generation)?.catch((error: unknown) =>
115
+ this.hooks.reporter.report(
116
+ "inbound-delivery",
117
+ `Intercom inbound delivery failed: ${asError(error).message}`,
118
+ ),
119
+ );
114
120
  }, delayMs);
115
121
  this.timer.unref();
116
122
  }
@@ -145,10 +151,19 @@ export class InboundDeliveryOwner {
145
151
  ) {
146
152
  this.schedule(0);
147
153
  }
154
+ this.hooks.reporter.recover("inbound-cleanup");
148
155
  };
149
- task.then(finish, finish).catch((error: unknown) => {
150
- console.error("Intercom inbound cleanup failed:", error);
151
- });
156
+ task
157
+ .then(() => {
158
+ this.hooks.reporter.recover("inbound-delivery");
159
+ finish();
160
+ }, finish)
161
+ .catch((error: unknown) => {
162
+ this.hooks.reporter.report(
163
+ "inbound-cleanup",
164
+ `Intercom inbound cleanup failed: ${asError(error).message}`,
165
+ );
166
+ });
152
167
  return task;
153
168
  }
154
169
  private triggerIndex(entries: readonly PendingInboundMessage[]): number {
@@ -13,8 +13,9 @@ import { IntercomInspection } from "./inspection.ts";
13
13
  import { IntercomTools } from "./tools.ts";
14
14
  import { IntercomSessionEvents } from "./session-events.ts";
15
15
  import { registerIntercomTools } from "./tool-registration.ts";
16
- import { isParkRequest, PARK, PARKED_HISTORY, WAKE } from "../shared/parking.ts";
16
+ import { isParkRequest, PARK, PARKED_HISTORY, WAKE, type WakeResult } from "../shared/parking.ts";
17
17
  import type { NativeHistory } from "./last-message.ts";
18
+ import { BackgroundReporter } from "../shared/background-reporting.ts";
18
19
 
19
20
  /** The agent runtime answers for its exited parked child from the captured native session. */
20
21
  function parkedHistory(pi: ExtensionAPI, peer: string): Promise<NativeHistory | undefined> {
@@ -28,34 +29,76 @@ function parkedHistory(pi: ExtensionAPI, peer: string): Promise<NativeHistory |
28
29
  return history ?? Promise.reject(new Error("No agent runtime owns parked history."));
29
30
  }
30
31
 
31
- export default function piIntercomExtension(pi: ExtensionAPI): void {
32
- const config = loadConfig();
32
+ function wakeParked(pi: ExtensionAPI, peer: string, signal?: AbortSignal): Promise<WakeResult> {
33
+ let result: Promise<WakeResult> | undefined;
34
+ pi.events.emit(WAKE, {
35
+ peer,
36
+ signal,
37
+ reply: (outcome: Promise<WakeResult>) => {
38
+ result = outcome;
39
+ },
40
+ });
41
+ return (
42
+ result ??
43
+ Promise.resolve({ status: "failed", reason: "No agent runtime owns this parked child." })
44
+ );
45
+ }
46
+
47
+ function setupReporting(pi: ExtensionAPI) {
48
+ let configError: string | undefined;
49
+ const config = loadConfig((message) => {
50
+ configError = message;
51
+ });
33
52
  const lifecycle = new IntercomLifecycle(pi, config, readIntercomLineage());
53
+ const reporter = new BackgroundReporter(() => lifecycle.live());
54
+ let pendingConfigError = configError;
55
+ pi.on("session_start", (_event, ctx) => {
56
+ reporter.start();
57
+ if (pendingConfigError !== undefined) {
58
+ if (ctx.hasUI) {
59
+ ctx.ui.notify(pendingConfigError, "warning");
60
+ } else {
61
+ console.error(pendingConfigError);
62
+ }
63
+ pendingConfigError = undefined;
64
+ }
65
+ });
66
+ pi.on("session_shutdown", () => reporter.shutdown());
67
+ return { config, lifecycle, reporter };
68
+ }
69
+
70
+ export default function piIntercomExtension(pi: ExtensionAPI): void {
71
+ const { config, lifecycle, reporter } = setupReporting(pi);
34
72
  const replies = new ReplyTracker(config.askTimeoutMs);
35
73
  const journal = new InboundJournal(pi, lifecycle, replies);
36
74
  const wait = new ReplyWait(lifecycle, config.askTimeoutMs);
37
- const connection = new IntercomConnection(lifecycle, config, {
38
- restoring: () => delivery.restoring,
39
- pendingAsks: () => replies.listPending().length,
40
- receipt: (query) => journal.receipt(query),
41
- incoming: (ctx, from, message) => inbound.receive(ctx, from, message),
42
- peerLeft: (id) => {
43
- wait.peerDisconnected(id);
44
- journal.peerLeft(id);
45
- },
46
- disconnected: (error) => {
47
- wait.reject(
48
- new Error(`Disconnected while waiting for reply: ${error.message}`, { cause: error }),
49
- );
50
- },
51
- parkedHistory: (peer) => parkedHistory(pi, peer.id),
52
- wake: (peer) => {
53
- pi.events.emit(WAKE, peer);
75
+ const connection = new IntercomConnection(
76
+ lifecycle,
77
+ config,
78
+ {
79
+ restoring: () => delivery.restoring,
80
+ pendingAsks: () => replies.listPending().length,
81
+ receipt: (query) => journal.receipt(query),
82
+ incoming: (ctx, from, message) => inbound.receive(ctx, from, message),
83
+ peerLeft: (id) => {
84
+ wait.peerDisconnected(id);
85
+ journal.peerLeft(id);
86
+ },
87
+ disconnected: (error) => {
88
+ wait.reject(
89
+ new Error(`Disconnected while waiting for reply: ${error.message}`, { cause: error }),
90
+ );
91
+ },
92
+ parkedHistory: (peer) => parkedHistory(pi, peer.id),
93
+ wake: (peer, signal) => wakeParked(pi, peer, signal),
94
+ wakeFailed: (peer, messageId, reason) => wait.wakeFailed(peer, messageId, reason),
54
95
  },
55
- });
96
+ reporter,
97
+ );
56
98
  const delivery = new InboundDeliveryOwner(pi, lifecycle, journal, {
57
99
  replies,
58
100
  syncStatus: () => connection.syncStatus(),
101
+ reporter,
59
102
  });
60
103
  lifecycle.registerContextEvents(() => connection.syncStatus());
61
104
  const inbound = new IntercomInbound(lifecycle, config, {
@@ -23,6 +23,7 @@ import {
23
23
  AskDeliveryError,
24
24
  failureDetails,
25
25
  RecoveryError,
26
+ WakeFailedError,
26
27
  type RecoveryAction,
27
28
  } from "./tool-results.ts";
28
29
  import { sendOptions, toolError, toolText } from "./tool-arguments.ts";
@@ -220,6 +221,18 @@ export class IntercomOutbound {
220
221
  );
221
222
  }
222
223
  private askFailure(error: unknown, to: string, questionId: string | undefined): ToolResultLike {
224
+ if (error instanceof WakeFailedError) {
225
+ return toolError(
226
+ `The parked child could not be woken: ${error.message} Your ask is still queued and may arrive after a later wake. Check the recipient before sending it again.`,
227
+ failureDetails("wake_failed", [{ action: "list" }, { action: "status" }], {
228
+ error: true,
229
+ messageId: questionId,
230
+ accepted: true,
231
+ delivered: false,
232
+ queued: true,
233
+ }),
234
+ );
235
+ }
223
236
  if (error instanceof AskDeliveryError) {
224
237
  const result = this.deliveryFailure(to, error.result);
225
238
  return { ...result, details: { ...result.details, error: true } };
@@ -390,16 +403,27 @@ export class IntercomOutbound {
390
403
  this.owners.replies.markReplied(target.message.id);
391
404
  this.markActivity();
392
405
  this.recordSent(sender, { text: body, replyTo: target.message.id }, result);
393
- return toolText(`Reply sent to ${sender}`, {
394
- messageId: result.id,
395
- delivered: true,
396
- replyTo: target.message.id,
397
- reasonCode: "reply_sent",
398
- });
406
+ return this.replySent(sender, target.message.id, result);
399
407
  } catch (error) {
400
408
  return this.replyFailure(error, replyTo);
401
409
  }
402
410
  }
411
+ private replySent(sender: string, replyTo: string, result: SendResult): ToolResultLike {
412
+ const queued = result.queued === true;
413
+ return toolText(
414
+ queued
415
+ ? `Reply queued for ${sender} (${result.reason ?? "queued"})`
416
+ : `Reply sent to ${sender}`,
417
+ {
418
+ messageId: result.id,
419
+ accepted: result.accepted,
420
+ delivered: result.delivered,
421
+ queued,
422
+ replyTo,
423
+ reasonCode: queued ? "message_queued" : "reply_sent",
424
+ },
425
+ );
426
+ }
403
427
  private replyFailure(error: unknown, replyTo: string | undefined): ToolResultLike {
404
428
  const reason = error instanceof RecoveryError ? error.code : "reply_failed";
405
429
  const actions: readonly RecoveryAction[] =
@@ -3,7 +3,7 @@ import type { IntercomClient, SendResult } from "./broker/client.ts";
3
3
  import type { IntercomTransport } from "./transport.ts";
4
4
  import type { IntercomLifecycle } from "./lifecycle.ts";
5
5
  import { asError } from "./validation.ts";
6
- import { AskDeliveryError, RecoveryError } from "./tool-results.ts";
6
+ import { AskDeliveryError, RecoveryError, WakeFailedError } from "./tool-results.ts";
7
7
 
8
8
  type Lifecycle = Readonly<Pick<IntercomLifecycle, "markActivity">>;
9
9
  interface ReplyWaiter {
@@ -47,6 +47,11 @@ export class ReplyWait {
47
47
  this.reject(new Error(`Reply peer disconnected before answering: ${id}`));
48
48
  }
49
49
  }
50
+ wakeFailed(peer: string, messageId: string, reason: string): void {
51
+ if (this.waiter?.from === peer && this.waiter.replyTo === messageId) {
52
+ this.reject(new WakeFailedError(reason));
53
+ }
54
+ }
50
55
  receive(
51
56
  sender: { readonly id: string; readonly name?: string },
52
57
  message: Message,
@@ -46,6 +46,7 @@ export class AskDeliveryError extends Error {
46
46
  this.result = result;
47
47
  }
48
48
  }
49
+ export class WakeFailedError extends Error {}
49
50
  export function throwIfToolError(result: ToolResultLike): ToolResultLike {
50
51
  if (result.isError !== true) {
51
52
  return result;
@@ -10,6 +10,10 @@ import {
10
10
  safeText,
11
11
  } from "./validation.ts";
12
12
 
13
+ export type WakeResult =
14
+ | { readonly status: "requested" }
15
+ | { readonly status: "failed"; readonly reason: string };
16
+
13
17
  export interface SessionInfo {
14
18
  readonly id: string;
15
19
  readonly name?: string;
@@ -0,0 +1,78 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { getPiAgentDir } from "../pi-intercom/agent-dir.ts";
5
+
6
+ /** One active failure per owner. Recovery is established by the owner, not by the reporter. */
7
+ export class BackgroundReporter {
8
+ private readonly episodes = new Map<string, string>();
9
+ private readonly context: () => ExtensionContext | null | undefined;
10
+ private shuttingDown = false;
11
+ private headless = false;
12
+ constructor(context: () => ExtensionContext | null | undefined) {
13
+ this.context = context;
14
+ }
15
+ start(): void {
16
+ this.shuttingDown = false;
17
+ }
18
+ shutdown(): void {
19
+ try {
20
+ this.headless = this.context()?.hasUI === false;
21
+ } catch {
22
+ this.headless = false;
23
+ }
24
+ this.shuttingDown = true;
25
+ }
26
+ recover(owner: string): void {
27
+ this.episodes.delete(owner);
28
+ }
29
+ private published(owner: string | undefined, message: string): void {
30
+ if (owner !== undefined) {
31
+ this.episodes.set(owner, message);
32
+ }
33
+ }
34
+ private repeated(owner: string | undefined, message: string): boolean {
35
+ return owner !== undefined && this.episodes.get(owner) === message;
36
+ }
37
+ report(
38
+ owner: string | undefined,
39
+ message: string,
40
+ severity: "warning" | "error" = "error",
41
+ ): void {
42
+ if (this.repeated(owner, message)) {
43
+ return;
44
+ }
45
+ if (this.shuttingDown && this.headless) {
46
+ console.error(message);
47
+ this.published(owner, message);
48
+ return;
49
+ }
50
+ try {
51
+ const ctx = this.context();
52
+ if (!this.shuttingDown && ctx) {
53
+ if (ctx.hasUI) {
54
+ ctx.ui.notify(message, severity);
55
+ } else {
56
+ console.error(message);
57
+ }
58
+ this.published(owner, message);
59
+ return;
60
+ }
61
+ } catch {
62
+ // A context may be invalidated between its owner's check and notification.
63
+ }
64
+ // Pi may have already stopped its renderer during session_shutdown. Never write to the terminal.
65
+ try {
66
+ const dir = path.join(getPiAgentDir(), "intercom");
67
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
68
+ fs.appendFileSync(
69
+ path.join(dir, "background-errors.log"),
70
+ `${new Date().toISOString()} ${message}\n`,
71
+ { mode: 0o600 },
72
+ );
73
+ this.published(owner, message);
74
+ } catch {
75
+ // No terminal write is safe here; the diagnostic store itself may be unavailable.
76
+ }
77
+ }
78
+ }
@@ -1,6 +1,9 @@
1
1
  import type { NativeHistory } from "../pi-intercom/last-message.ts";
2
+ import type { WakeResult } from "../pi-intercom/types.ts";
2
3
  import { isRecord } from "./unknown.ts";
3
4
 
5
+ export type { WakeResult } from "../pi-intercom/types.ts";
6
+
4
7
  /** A child actor asks its intercom to fence broker input before parking, or to release it. */
5
8
  export const PARK = "pi-subagents:park";
6
9
  export interface ParkRequest {
@@ -10,6 +13,11 @@ export interface ParkRequest {
10
13
  /** A main intercom wakes or reads an exited parked child through the agent runtime. */
11
14
  export const WAKE = "pi-subagents:wake";
12
15
  export const PARKED_HISTORY = "pi-subagents:parked-history";
16
+ export interface WakeRequest {
17
+ readonly peer: string;
18
+ readonly signal?: AbortSignal;
19
+ readonly reply: (result: Promise<WakeResult>) => void;
20
+ }
13
21
  export interface HistoryRequest {
14
22
  readonly peer: string;
15
23
  readonly reply: (history: Promise<NativeHistory | undefined>) => void;
@@ -17,6 +25,14 @@ export interface HistoryRequest {
17
25
  export function isParkRequest(value: unknown): value is ParkRequest {
18
26
  return isRecord(value) && typeof value.park === "boolean" && typeof value.reply === "function";
19
27
  }
28
+ export function isWakeRequest(value: unknown): value is WakeRequest {
29
+ return (
30
+ isRecord(value) &&
31
+ typeof value.peer === "string" &&
32
+ typeof value.reply === "function" &&
33
+ (value.signal === undefined || value.signal instanceof AbortSignal)
34
+ );
35
+ }
20
36
  export function isHistoryRequest(value: unknown): value is HistoryRequest {
21
37
  return isRecord(value) && typeof value.peer === "string" && typeof value.reply === "function";
22
38
  }