@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
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "crypto";
2
2
  import { nativePeerId } from "../session-targets.js";
3
- import { writeMessage } from "./framing.js";
3
+ import { isRecord } from "../../shared/unknown.js";
4
+ import { validateIntercomMessageSize, writeMessage } from "./framing.js";
4
5
  export function reachable(target) {
5
6
  const socket = target?.socket;
6
7
  return socket !== undefined && !socket.destroyed && socket.writable && !socket.writableEnded;
@@ -8,6 +9,17 @@ export function reachable(target) {
8
9
  function requested(id) {
9
10
  return typeof id === "string" && /^[a-zA-Z0-9_-]{8,80}$/.test(id) ? id : undefined;
10
11
  }
12
+ function wakeResponse(msg) {
13
+ if (typeof msg.requestId === "string" && isRecord(msg.result)) {
14
+ if (msg.result.status === "requested") {
15
+ return { id: msg.requestId, result: { status: "requested" } };
16
+ }
17
+ if (msg.result.status === "failed" && typeof msg.result.reason === "string") {
18
+ return { id: msg.requestId, result: { status: "failed", reason: msg.result.reason } };
19
+ }
20
+ }
21
+ throw new Error("Invalid wake response");
22
+ }
11
23
  /** A fenced child keeps its identity and queued input; it never departs while parked. */
12
24
  export class ParkedSessions {
13
25
  sessions;
@@ -16,6 +28,20 @@ export class ParkedSessions {
16
28
  this.sessions = sessions;
17
29
  this.deliver = deliver;
18
30
  }
31
+ handle(socket, id, msg) {
32
+ if (msg.type === "park" || msg.type === "unpark") {
33
+ this.park(socket, id, msg.type === "park");
34
+ return true;
35
+ }
36
+ if (msg.type === "wake_result") {
37
+ const owner = this.sessions.get(id);
38
+ if (owner) {
39
+ this.respond(owner, msg);
40
+ }
41
+ return true;
42
+ }
43
+ return false;
44
+ }
19
45
  park(socket, id, on) {
20
46
  const session = this.sessions.get(id);
21
47
  if (on && session?.parking === true) {
@@ -57,6 +83,7 @@ export class ParkedSessions {
57
83
  }
58
84
  /** Flushes a restored child's input and re-sends wakes owed to a (re)registered main. */
59
85
  registered(id, previous) {
86
+ this.registeredWake(previous);
60
87
  this.flush(id, previous?.parked?.retained ?? []);
61
88
  for (const child of this.sessions.values()) {
62
89
  if (nativePeerId(child.info.rootSessionId ?? "") === id) {
@@ -64,6 +91,26 @@ export class ParkedSessions {
64
91
  }
65
92
  }
66
93
  }
94
+ registeredWake(previous) {
95
+ const main = previous && this.main(previous);
96
+ const attempt = previous?.parked?.attempt;
97
+ if (reachable(main) && attempt?.owner === main.socket) {
98
+ writeMessage(main.socket, { type: "wake_registered", requestId: attempt.id });
99
+ }
100
+ }
101
+ capabilities(msg) {
102
+ return {
103
+ ...(msg.wakeResults === 1 ? { wakeResults: 1 } : {}),
104
+ ...(msg.wakeNotices === 1 ? { wakeNotices: 1 } : {}),
105
+ };
106
+ }
107
+ features(msg) {
108
+ return {
109
+ parking: msg.parking === true,
110
+ wakeResults: msg.wakeResults === 1,
111
+ wakeNotices: msg.wakeNotices === 1,
112
+ };
113
+ }
67
114
  /** A transport handoff, not a departure: no session_left and queued input stays. */
68
115
  closed(session) {
69
116
  const { parked } = session;
@@ -81,12 +128,21 @@ export class ParkedSessions {
81
128
  return [...this.sessions.values()].every(({ parked }) => parked?.closed === true && parked.retained.length === 0);
82
129
  }
83
130
  retain(target, from, message) {
84
- target?.parked?.retained.push({ from, message });
85
- if (target) {
131
+ target?.parked?.retained.push({ from, message, sender: this.sessions.get(from.id)?.socket });
132
+ if (target && this.waking(message)) {
86
133
  this.wake(target);
87
134
  }
88
135
  return target?.parked !== undefined;
89
136
  }
137
+ queueReason(target, messageId) {
138
+ const last = target?.parked?.lastFailure;
139
+ const reason = last === undefined
140
+ ? "Queued for a parked child; delivery awaits its return."
141
+ : `Queued for a parked child; the last wake failed: ${last} The message may still arrive after a later wake.`;
142
+ return validateIntercomMessageSize({ type: "delivery_queued", messageId, reason }) === null
143
+ ? reason
144
+ : "Queued for a parked child; its last wake failed. Inspect the agent run for details.";
145
+ }
90
146
  /** Reads of an exited parked child go to its main, which answers from captured history. */
91
147
  reader(id) {
92
148
  const child = this.sessions.get(id);
@@ -95,10 +151,71 @@ export class ParkedSessions {
95
151
  // Passive breadcrumbs and topic updates wait for a genuine wake; they never spawn the child.
96
152
  wake(child) {
97
153
  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) {
154
+ const parked = child.parked;
155
+ if (!reachable(main) ||
156
+ parked?.closed !== true ||
157
+ !parked.retained.some(({ message }) => this.waking(message))) {
158
+ return;
159
+ }
160
+ if (!main.wakeResults) {
100
161
  writeMessage(main.socket, { type: "wake", sessionId: child.info.id });
162
+ return;
163
+ }
164
+ if (parked.attempt?.owner === main.socket) {
165
+ parked.attempt.retry = true;
166
+ return;
167
+ }
168
+ const attempt = {
169
+ id: randomUUID(),
170
+ owner: main.socket,
171
+ count: parked.retained.length,
172
+ retry: false,
173
+ };
174
+ parked.attempt = attempt;
175
+ writeMessage(main.socket, { type: "wake", sessionId: child.info.id, requestId: attempt.id });
176
+ }
177
+ waking(message) {
178
+ return (message.passive !== true && message.delivery !== "passive" && message.topic === undefined);
179
+ }
180
+ /** Only the main sent this request can report it, and only while that child stays parked. */
181
+ respond(owner, msg) {
182
+ const { id, result } = wakeResponse(msg);
183
+ const child = [...this.sessions.values()].find((peer) => peer.parked?.attempt?.id === id &&
184
+ peer.parked.attempt.owner === owner.socket &&
185
+ this.main(peer) === owner);
186
+ const parked = child?.parked;
187
+ const attempt = parked?.attempt;
188
+ if (!child || !parked || !attempt) {
189
+ return;
190
+ }
191
+ parked.attempt = undefined;
192
+ if (result.status === "failed") {
193
+ parked.lastFailure = result.reason;
194
+ for (const retained of parked.retained.slice(0, attempt.count)) {
195
+ this.notify(child, retained, result.reason);
196
+ }
197
+ }
198
+ if (attempt.retry) {
199
+ this.wake(child);
200
+ }
201
+ }
202
+ notify(child, { from, sender, message }, reason) {
203
+ const origin = this.sessions.get(from.id);
204
+ if (message.expectsReply !== true ||
205
+ origin?.wakeNotices !== true ||
206
+ origin.socket !== sender ||
207
+ !reachable(origin)) {
208
+ return;
101
209
  }
210
+ const frame = {
211
+ type: "wake_failed",
212
+ sessionId: child.info.id,
213
+ messageId: message.id,
214
+ reason,
215
+ };
216
+ writeMessage(origin.socket, validateIntercomMessageSize(frame) === null
217
+ ? frame
218
+ : { ...frame, reason: "Wake failed; inspect the agent run for details." });
102
219
  }
103
220
  main(child) {
104
221
  const main = this.sessions.get(nativePeerId(child.info.rootSessionId ?? ""));
@@ -44,9 +44,13 @@ export function getBrokerSpawnOptions() {
44
44
  function toError(error) {
45
45
  return error instanceof Error ? error : new Error(errorMessage(error));
46
46
  }
47
- export async function spawnBrokerIfNeeded(brokerCommand, brokerArgs) {
47
+ export async function spawnBrokerIfNeeded(brokerCommand, brokerArgs, warning = (message) => {
48
+ if (message !== undefined) {
49
+ console.warn(message);
50
+ }
51
+ }) {
48
52
  prepareBrokerSocketPath();
49
- await warnPreviousBroker();
53
+ warning(await previousBrokerWarning());
50
54
  if (await isBrokerRunning()) {
51
55
  return;
52
56
  }
@@ -130,20 +134,20 @@ function readLivePid(pidPath) {
130
134
  }
131
135
  return isBrokerPidReused(pid, raw) ? null : pid;
132
136
  }
133
- async function warnPreviousBroker() {
137
+ async function previousBrokerWarning() {
134
138
  const previousSockets = getPreviousBrokerSocketPaths();
135
139
  for (const socketPath of previousSockets) {
136
140
  // Migration probes are sequential and never register or send to old peers.
137
141
  // oxlint-disable-next-line no-await-in-loop
138
142
  if (isOwnedBrokerSocket(socketPath) && (await checkOneSocket(socketPath))) {
139
- console.warn("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.");
140
- return;
143
+ 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.";
141
144
  }
142
145
  }
143
146
  const previousPid = readLivePid(join(getPiAgentDir(), "intercom", "broker.pid"));
144
147
  if (previousPid !== null) {
145
- console.warn(`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.`);
148
+ 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.`;
146
149
  }
150
+ return undefined;
147
151
  }
148
152
  export async function stopUnhealthyBrokerBeforeSpawn() {
149
153
  if (await checkSocketConnectable()) {
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { getPiAgentDir } from "./agent-dir.js";
4
4
  import { isRecord, isUnknownArray } from "./validation.js";
5
+ import { errorMessage } from "../shared/unknown.js";
5
6
  const defaults = {
6
7
  brokerCommand: process.execPath,
7
8
  brokerArgs: [],
@@ -65,7 +66,7 @@ function configuredTimeout(parsed, key) {
65
66
  }
66
67
  return value;
67
68
  }
68
- export function loadConfig() {
69
+ export function loadConfig(report = console.error) {
69
70
  const configPath = join(getPiAgentDir(), "intercom", "config.json");
70
71
  if (!existsSync(configPath)) {
71
72
  return { ...defaults };
@@ -87,7 +88,7 @@ export function loadConfig() {
87
88
  };
88
89
  }
89
90
  catch (error) {
90
- console.error(`Failed to load intercom config at ${configPath}:`, error);
91
+ report(`Failed to load intercom config at ${configPath}: ${errorMessage(error)}. Using defaults.`);
91
92
  return { ...defaults };
92
93
  }
93
94
  }
@@ -17,10 +17,13 @@ export class IntercomConnection {
17
17
  lifecycle;
18
18
  config;
19
19
  hooks;
20
- constructor(lifecycle, config, hooks) {
20
+ wakes = new Map();
21
+ reporter;
22
+ constructor(lifecycle, config, hooks, reporter) {
21
23
  this.lifecycle = lifecycle;
22
24
  this.config = config;
23
25
  this.hooks = hooks;
26
+ this.reporter = reporter;
24
27
  }
25
28
  get active() {
26
29
  return this.client;
@@ -157,6 +160,17 @@ export class IntercomConnection {
157
160
  next.respondLast(request.requestId, { error: asError(error).message });
158
161
  }
159
162
  }
163
+ receive(next, from, message) {
164
+ const ctx = this.lifecycle.live();
165
+ if (this.client !== next || !ctx) {
166
+ return;
167
+ }
168
+ const owner = `inbound:${from.id}:${message.id}`;
169
+ this.hooks
170
+ .incoming(ctx, from, message)
171
+ .then(() => this.reporter.recover(owner))
172
+ .catch((error) => this.reporter.report(owner, `Intercom inbound ${message.id} from ${from.id} failed: ${asError(error).message}`));
173
+ }
160
174
  createClient() {
161
175
  const next = new IntercomClient({
162
176
  sendTimeoutMs: this.config.sendTimeoutMs,
@@ -167,17 +181,48 @@ export class IntercomConnection {
167
181
  this.answerLast(next, request);
168
182
  });
169
183
  next.on("message", (from, message) => {
170
- const ctx = this.lifecycle.live();
171
- if (this.client !== next || !ctx) {
172
- return;
173
- }
174
- this.hooks.incoming(ctx, from, message).catch((error) => {
175
- console.error("Intercom inbound delivery failed:", error);
176
- });
184
+ this.receive(next, from, message);
177
185
  });
178
186
  next.on("wake", (peer) => {
179
187
  if (this.client === next) {
180
- this.hooks.wake(peer);
188
+ void this.hooks.wake(peer).catch(() => {
189
+ // Old brokers cannot receive wake outcomes.
190
+ });
191
+ }
192
+ });
193
+ next.on("wake_request", (peer, requestId) => {
194
+ if (this.client === next) {
195
+ const registered = new AbortController();
196
+ this.wakes.set(requestId, registered);
197
+ void this.hooks
198
+ .wake(peer, registered.signal)
199
+ .catch((error) => ({
200
+ status: "failed",
201
+ reason: asError(error).message,
202
+ }))
203
+ .then((result) => {
204
+ if (this.client === next && !registered.signal.aborted) {
205
+ next.wakes.respond(requestId, result);
206
+ }
207
+ })
208
+ .finally(() => {
209
+ if (this.wakes.get(requestId) === registered) {
210
+ this.wakes.delete(requestId);
211
+ }
212
+ })
213
+ .catch(() => {
214
+ // A lost broker transport retains the input for the next registration.
215
+ });
216
+ }
217
+ });
218
+ next.on("wake_registered", (requestId) => {
219
+ if (this.client === next) {
220
+ this.wakes.get(requestId)?.abort();
221
+ }
222
+ });
223
+ next.on("wake_failed", (peer, messageId, reason) => {
224
+ if (this.client === next) {
225
+ this.hooks.wakeFailed(peer, messageId, reason);
181
226
  }
182
227
  });
183
228
  next.on("session_left", (id) => {
@@ -191,6 +236,7 @@ export class IntercomConnection {
191
236
  return;
192
237
  }
193
238
  this.hooks.disconnected(error);
239
+ this.cancelWakes();
194
240
  this.client = null;
195
241
  this.lifecycle.transport(null);
196
242
  this.clearReconnect();
@@ -204,7 +250,14 @@ export class IntercomConnection {
204
250
  async connect(ctx, generation, sessionId) {
205
251
  const next = this.createClient();
206
252
  try {
207
- await spawnBrokerIfNeeded(this.config.brokerCommand, this.config.brokerArgs);
253
+ await spawnBrokerIfNeeded(this.config.brokerCommand, this.config.brokerArgs, (message) => {
254
+ if (message === undefined) {
255
+ this.reporter.recover("older-broker");
256
+ }
257
+ else {
258
+ this.reporter.report("older-broker", message, "warning");
259
+ }
260
+ });
208
261
  await next.connect(await this.lifecycle.registration(this.hooks.pendingAsks()), nativePeerId(sessionId));
209
262
  if (!this.lifecycle.live(ctx, generation)) {
210
263
  await next.disconnect();
@@ -214,6 +267,7 @@ export class IntercomConnection {
214
267
  this.lifecycle.transport(next.sessionId, next.supportsGuardedDelivery);
215
268
  this.syncIdentity(ctx.sessionManager.getSessionId());
216
269
  this.attempt = 0;
270
+ this.reporter.recover("connection-cleanup");
217
271
  return next;
218
272
  }
219
273
  catch (error) {
@@ -222,7 +276,7 @@ export class IntercomConnection {
222
276
  }
223
277
  // A failed registration still owns its socket.
224
278
  await next.disconnect().catch((cleanupError) => {
225
- console.error("Intercom failed connection cleanup:", cleanupError);
279
+ this.reporter.report("connection-cleanup", `Intercom failed connection cleanup: ${asError(cleanupError).message}`);
226
280
  });
227
281
  throw asError(error);
228
282
  }
@@ -275,6 +329,7 @@ export class IntercomConnection {
275
329
  }
276
330
  async shutdown() {
277
331
  this.clearTimers();
332
+ this.cancelWakes();
278
333
  // The bounded launcher attempt is joined before socket shutdown.
279
334
  await Promise.allSettled([this.connecting]);
280
335
  if (this.client) {
@@ -282,6 +337,12 @@ export class IntercomConnection {
282
337
  this.client = null;
283
338
  }
284
339
  }
340
+ cancelWakes() {
341
+ for (const wake of this.wakes.values()) {
342
+ wake.abort();
343
+ }
344
+ this.wakes.clear();
345
+ }
285
346
  }
286
347
  export async function resolveConnectedTarget(active, name) {
287
348
  const sessions = await active.listSessions();
@@ -1,5 +1,6 @@
1
1
  import { requestedDelivery } from "./inbound-record.js";
2
2
  import { runCooperatively } from "../shared/cooperative.js";
3
+ import { asError } from "./validation.js";
3
4
  /** Owns cooperative inbound batches, restoration and the flush timer. */
4
5
  export class InboundDeliveryOwner {
5
6
  timer = null;
@@ -74,9 +75,7 @@ export class InboundDeliveryOwner {
74
75
  this.clearTimer();
75
76
  this.timer = setTimeout(() => {
76
77
  this.timer = null;
77
- this.flush(generation)?.catch((error) => {
78
- console.error("Intercom inbound delivery failed:", error);
79
- });
78
+ this.flush(generation)?.catch((error) => this.hooks.reporter.report("inbound-delivery", `Intercom inbound delivery failed: ${asError(error).message}`));
80
79
  }, delayMs);
81
80
  this.timer.unref();
82
81
  }
@@ -105,9 +104,15 @@ export class InboundDeliveryOwner {
105
104
  this.journal.queued().length > 0) {
106
105
  this.schedule(0);
107
106
  }
107
+ this.hooks.reporter.recover("inbound-cleanup");
108
108
  };
109
- task.then(finish, finish).catch((error) => {
110
- console.error("Intercom inbound cleanup failed:", error);
109
+ task
110
+ .then(() => {
111
+ this.hooks.reporter.recover("inbound-delivery");
112
+ finish();
113
+ }, finish)
114
+ .catch((error) => {
115
+ this.hooks.reporter.report("inbound-cleanup", `Intercom inbound cleanup failed: ${asError(error).message}`);
111
116
  });
112
117
  return task;
113
118
  }
@@ -13,6 +13,7 @@ import { IntercomTools } from "./tools.js";
13
13
  import { IntercomSessionEvents } from "./session-events.js";
14
14
  import { registerIntercomTools } from "./tool-registration.js";
15
15
  import { isParkRequest, PARK, PARKED_HISTORY, WAKE } from "../shared/parking.js";
16
+ import { BackgroundReporter } from "../shared/background-reporting.js";
16
17
  /** The agent runtime answers for its exited parked child from the captured native session. */
17
18
  function parkedHistory(pi, peer) {
18
19
  let history;
@@ -24,9 +25,43 @@ function parkedHistory(pi, peer) {
24
25
  });
25
26
  return history ?? Promise.reject(new Error("No agent runtime owns parked history."));
26
27
  }
27
- export default function piIntercomExtension(pi) {
28
- const config = loadConfig();
28
+ function wakeParked(pi, peer, signal) {
29
+ let result;
30
+ pi.events.emit(WAKE, {
31
+ peer,
32
+ signal,
33
+ reply: (outcome) => {
34
+ result = outcome;
35
+ },
36
+ });
37
+ return (result ??
38
+ Promise.resolve({ status: "failed", reason: "No agent runtime owns this parked child." }));
39
+ }
40
+ function setupReporting(pi) {
41
+ let configError;
42
+ const config = loadConfig((message) => {
43
+ configError = message;
44
+ });
29
45
  const lifecycle = new IntercomLifecycle(pi, config, readIntercomLineage());
46
+ const reporter = new BackgroundReporter(() => lifecycle.live());
47
+ let pendingConfigError = configError;
48
+ pi.on("session_start", (_event, ctx) => {
49
+ reporter.start();
50
+ if (pendingConfigError !== undefined) {
51
+ if (ctx.hasUI) {
52
+ ctx.ui.notify(pendingConfigError, "warning");
53
+ }
54
+ else {
55
+ console.error(pendingConfigError);
56
+ }
57
+ pendingConfigError = undefined;
58
+ }
59
+ });
60
+ pi.on("session_shutdown", () => reporter.shutdown());
61
+ return { config, lifecycle, reporter };
62
+ }
63
+ export default function piIntercomExtension(pi) {
64
+ const { config, lifecycle, reporter } = setupReporting(pi);
30
65
  const replies = new ReplyTracker(config.askTimeoutMs);
31
66
  const journal = new InboundJournal(pi, lifecycle, replies);
32
67
  const wait = new ReplyWait(lifecycle, config.askTimeoutMs);
@@ -43,13 +78,13 @@ export default function piIntercomExtension(pi) {
43
78
  wait.reject(new Error(`Disconnected while waiting for reply: ${error.message}`, { cause: error }));
44
79
  },
45
80
  parkedHistory: (peer) => parkedHistory(pi, peer.id),
46
- wake: (peer) => {
47
- pi.events.emit(WAKE, peer);
48
- },
49
- });
81
+ wake: (peer, signal) => wakeParked(pi, peer, signal),
82
+ wakeFailed: (peer, messageId, reason) => wait.wakeFailed(peer, messageId, reason),
83
+ }, reporter);
50
84
  const delivery = new InboundDeliveryOwner(pi, lifecycle, journal, {
51
85
  replies,
52
86
  syncStatus: () => connection.syncStatus(),
87
+ reporter,
53
88
  });
54
89
  lifecycle.registerContextEvents(() => connection.syncStatus());
55
90
  const inbound = new IntercomInbound(lifecycle, config, {
@@ -3,7 +3,7 @@ import { resolveConnectedTarget, resolvePeerHealth, } from "./connection.js";
3
3
  import { RECIPIENT_TURN_FAILED_ATTACHMENT, } from "./runtime-types.js";
4
4
  import { errorMessage } from "./validation.js";
5
5
  import { formatAttachments } from "./message-format.js";
6
- import { AskDeliveryError, failureDetails, RecoveryError, } from "./tool-results.js";
6
+ import { AskDeliveryError, failureDetails, RecoveryError, WakeFailedError, } from "./tool-results.js";
7
7
  import { sendOptions, toolError, toolText } from "./tool-arguments.js";
8
8
  export class IntercomOutbound {
9
9
  owners;
@@ -142,6 +142,15 @@ export class IntercomOutbound {
142
142
  return toolText(`**Reply from ${to}:**\n${text}${attachments !== undefined && attachments.length > 0 ? formatAttachments(attachments) : ""}`);
143
143
  }
144
144
  askFailure(error, to, questionId) {
145
+ if (error instanceof WakeFailedError) {
146
+ return toolError(`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.`, failureDetails("wake_failed", [{ action: "list" }, { action: "status" }], {
147
+ error: true,
148
+ messageId: questionId,
149
+ accepted: true,
150
+ delivered: false,
151
+ queued: true,
152
+ }));
153
+ }
145
154
  if (error instanceof AskDeliveryError) {
146
155
  const result = this.deliveryFailure(to, error.result);
147
156
  return { ...result, details: { ...result.details, error: true } };
@@ -272,17 +281,25 @@ export class IntercomOutbound {
272
281
  this.owners.replies.markReplied(target.message.id);
273
282
  this.markActivity();
274
283
  this.recordSent(sender, { text: body, replyTo: target.message.id }, result);
275
- return toolText(`Reply sent to ${sender}`, {
276
- messageId: result.id,
277
- delivered: true,
278
- replyTo: target.message.id,
279
- reasonCode: "reply_sent",
280
- });
284
+ return this.replySent(sender, target.message.id, result);
281
285
  }
282
286
  catch (error) {
283
287
  return this.replyFailure(error, replyTo);
284
288
  }
285
289
  }
290
+ replySent(sender, replyTo, result) {
291
+ const queued = result.queued === true;
292
+ return toolText(queued
293
+ ? `Reply queued for ${sender} (${result.reason ?? "queued"})`
294
+ : `Reply sent to ${sender}`, {
295
+ messageId: result.id,
296
+ accepted: result.accepted,
297
+ delivered: result.delivered,
298
+ queued,
299
+ replyTo,
300
+ reasonCode: queued ? "message_queued" : "reply_sent",
301
+ });
302
+ }
286
303
  replyFailure(error, replyTo) {
287
304
  const reason = error instanceof RecoveryError ? error.code : "reply_failed";
288
305
  const actions = reason === "no_pending_reply"
@@ -1,5 +1,5 @@
1
1
  import { asError } from "./validation.js";
2
- import { AskDeliveryError, RecoveryError } from "./tool-results.js";
2
+ import { AskDeliveryError, RecoveryError, WakeFailedError } from "./tool-results.js";
3
3
  /** One correlated blocking wait; admitted before send so a fast reply cannot race it. */
4
4
  export class ReplyWait {
5
5
  waiter = null;
@@ -23,6 +23,11 @@ export class ReplyWait {
23
23
  this.reject(new Error(`Reply peer disconnected before answering: ${id}`));
24
24
  }
25
25
  }
26
+ wakeFailed(peer, messageId, reason) {
27
+ if (this.waiter?.from === peer && this.waiter.replyTo === messageId) {
28
+ this.reject(new WakeFailedError(reason));
29
+ }
30
+ }
26
31
  receive(sender, message, onMatch) {
27
32
  const waiter = this.waiter;
28
33
  if (!waiter || sender.id !== waiter.from || message.replyTo !== waiter.replyTo) {
@@ -27,6 +27,8 @@ export class AskDeliveryError extends Error {
27
27
  this.result = result;
28
28
  }
29
29
  }
30
+ export class WakeFailedError extends Error {
31
+ }
30
32
  export function throwIfToolError(result) {
31
33
  if (result.isError !== true) {
32
34
  return result;
@@ -1,3 +1,9 @@
1
+ export type WakeResult = {
2
+ readonly status: "requested";
3
+ } | {
4
+ readonly status: "failed";
5
+ readonly reason: string;
6
+ };
1
7
  export interface SessionInfo {
2
8
  readonly id: string;
3
9
  readonly name?: string;
@@ -0,0 +1,72 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { getPiAgentDir } from "../pi-intercom/agent-dir.js";
4
+ /** One active failure per owner. Recovery is established by the owner, not by the reporter. */
5
+ export class BackgroundReporter {
6
+ episodes = new Map();
7
+ context;
8
+ shuttingDown = false;
9
+ headless = false;
10
+ constructor(context) {
11
+ this.context = context;
12
+ }
13
+ start() {
14
+ this.shuttingDown = false;
15
+ }
16
+ shutdown() {
17
+ try {
18
+ this.headless = this.context()?.hasUI === false;
19
+ }
20
+ catch {
21
+ this.headless = false;
22
+ }
23
+ this.shuttingDown = true;
24
+ }
25
+ recover(owner) {
26
+ this.episodes.delete(owner);
27
+ }
28
+ published(owner, message) {
29
+ if (owner !== undefined) {
30
+ this.episodes.set(owner, message);
31
+ }
32
+ }
33
+ repeated(owner, message) {
34
+ return owner !== undefined && this.episodes.get(owner) === message;
35
+ }
36
+ report(owner, message, severity = "error") {
37
+ if (this.repeated(owner, message)) {
38
+ return;
39
+ }
40
+ if (this.shuttingDown && this.headless) {
41
+ console.error(message);
42
+ this.published(owner, message);
43
+ return;
44
+ }
45
+ try {
46
+ const ctx = this.context();
47
+ if (!this.shuttingDown && ctx) {
48
+ if (ctx.hasUI) {
49
+ ctx.ui.notify(message, severity);
50
+ }
51
+ else {
52
+ console.error(message);
53
+ }
54
+ this.published(owner, message);
55
+ return;
56
+ }
57
+ }
58
+ catch {
59
+ // A context may be invalidated between its owner's check and notification.
60
+ }
61
+ // Pi may have already stopped its renderer during session_shutdown. Never write to the terminal.
62
+ try {
63
+ const dir = path.join(getPiAgentDir(), "intercom");
64
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
65
+ fs.appendFileSync(path.join(dir, "background-errors.log"), `${new Date().toISOString()} ${message}\n`, { mode: 0o600 });
66
+ this.published(owner, message);
67
+ }
68
+ catch {
69
+ // No terminal write is safe here; the diagnostic store itself may be unavailable.
70
+ }
71
+ }
72
+ }