@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.
- package/CHANGELOG.md +17 -0
- package/README.md +5 -4
- package/dist/agent/completion-watches.js +61 -0
- package/dist/agent/contracts.js +9 -0
- package/dist/agent/host.js +5 -0
- package/dist/agent/index.js +20 -51
- package/dist/agent/legacy.js +41 -11
- package/dist/agent/notifications.js +2 -2
- package/dist/agent/parking.js +121 -13
- package/dist/agent/process.js +107 -63
- package/dist/agent/receipts.js +21 -4
- package/dist/agent/runner.js +19 -5
- package/dist/agent/shutdown.js +43 -0
- package/dist/agent/view.js +5 -1
- package/dist/build-info.js +1 -1
- package/dist/pi-intercom/bridge-session.js +16 -3
- package/dist/pi-intercom/broker/broker.js +7 -6
- package/dist/pi-intercom/broker/client-wake.d.ts +17 -0
- package/dist/pi-intercom/broker/client-wake.js +54 -0
- package/dist/pi-intercom/broker/client.d.ts +2 -0
- package/dist/pi-intercom/broker/client.js +23 -16
- package/dist/pi-intercom/broker/parked.js +122 -5
- package/dist/pi-intercom/broker/spawn.js +10 -6
- package/dist/pi-intercom/config.js +3 -2
- package/dist/pi-intercom/connection.js +72 -11
- package/dist/pi-intercom/inbound-delivery.js +10 -5
- package/dist/pi-intercom/index.js +41 -6
- package/dist/pi-intercom/outbound.js +24 -7
- package/dist/pi-intercom/reply-wait.js +6 -1
- package/dist/pi-intercom/tool-results.js +2 -0
- package/dist/pi-intercom/types.d.ts +6 -0
- package/dist/shared/background-reporting.js +72 -0
- package/dist/shared/parking.js +6 -0
- package/docs/development.md +2 -0
- package/docs/intercom.md +14 -0
- package/docs/performance.md +2 -2
- package/docs/reference.md +13 -5
- package/package.json +1 -1
- package/src/agent/completion-watches.ts +73 -0
- package/src/agent/contracts.ts +11 -0
- package/src/agent/host.ts +6 -0
- package/src/agent/index.ts +24 -54
- package/src/agent/legacy.ts +44 -12
- package/src/agent/notifications.ts +2 -3
- package/src/agent/parking.ts +148 -12
- package/src/agent/process.ts +130 -66
- package/src/agent/receipts.ts +25 -4
- package/src/agent/runner.ts +25 -5
- package/src/agent/shutdown.ts +50 -0
- package/src/agent/view.ts +9 -0
- package/src/pi-intercom/bridge-session.ts +14 -9
- package/src/pi-intercom/broker/broker.ts +8 -7
- package/src/pi-intercom/broker/client-wake.ts +61 -0
- package/src/pi-intercom/broker/client.ts +23 -17
- package/src/pi-intercom/broker/parked.ts +152 -10
- package/src/pi-intercom/broker/spawn.ts +10 -9
- package/src/pi-intercom/config.ts +5 -2
- package/src/pi-intercom/connection.ts +88 -12
- package/src/pi-intercom/inbound-delivery.ts +21 -6
- package/src/pi-intercom/index.ts +64 -21
- package/src/pi-intercom/outbound.ts +30 -6
- package/src/pi-intercom/reply-wait.ts +6 -1
- package/src/pi-intercom/tool-results.ts +1 -0
- package/src/pi-intercom/types.ts +4 -0
- package/src/shared/background-reporting.ts +78 -0
- 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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
150
|
-
|
|
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 {
|
package/src/pi-intercom/index.ts
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
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(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
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,
|
package/src/pi-intercom/types.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/shared/parking.ts
CHANGED
|
@@ -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
|
}
|