pi-agent-squad 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -15
- package/active-runs.ts +87 -0
- package/agents/actor.md +14 -1
- package/agents/planner.md +1 -1
- package/agents/reviewer.md +1 -1
- package/agents.ts +27 -6
- package/index.ts +437 -135
- package/message.ts +605 -112
- package/orchestrator.md +121 -124
- package/package.json +1 -1
- package/pool.ts +387 -167
- package/session-ui.ts +44 -3
- package/session.ts +7 -0
- package/spawn.ts +232 -69
package/message.ts
CHANGED
|
@@ -9,10 +9,15 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
9
9
|
//
|
|
10
10
|
// Two parties communicate: `main` (the main agent) and any subagent. Identity
|
|
11
11
|
// is defined entirely by each agent's prompt.
|
|
12
|
-
// Messages = file channel + polling
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
12
|
+
// Messages = file channel + polling. Every file lives in the SENDER's channel
|
|
13
|
+
// directory; the main-side router scans the tree and delivers:
|
|
14
|
+
// send_message -> <root>/<runId>/<sender>/<idx>/requests/<msgId>.json
|
|
15
|
+
// reply_message -> <root>/<runId>/<sender>/<idx>/replies/<msgId>.json
|
|
16
|
+
// (written back into the ORIGINAL sender's channel)
|
|
17
|
+
// Channel semantics that follow from this:
|
|
18
|
+
// requests/ = this channel's OUTBOX (messages it sent)
|
|
19
|
+
// replies/ = this channel's INBOX (answers addressed to it)
|
|
20
|
+
// a request file whose `to` is this channel is also inbox content
|
|
16
21
|
// Main-side routing: to === "main" -> inject into main session; otherwise
|
|
17
22
|
// forward to the target subagent's resident process.
|
|
18
23
|
// ============================================================================
|
|
@@ -43,12 +48,33 @@ const MAX_ROUTE_TIMEOUT_SECONDS = 3 * 24 * 60 * 60;
|
|
|
43
48
|
const CHANNEL_POLL_MS = 500;
|
|
44
49
|
const REPLY_POLL_MS = 250;
|
|
45
50
|
|
|
51
|
+
// Delivery retry: a failing delivery must never be treated as success, but it
|
|
52
|
+
// also must not spin on every 500ms poll forever. Retries back off, and a
|
|
53
|
+
// permanently undeliverable message is dropped (file removed, waiter released).
|
|
54
|
+
const MAX_DELIVERY_ATTEMPTS = 5;
|
|
55
|
+
const RETRY_BACKOFF_BASE_MS = 1000;
|
|
56
|
+
const RETRY_BACKOFF_MAX_MS = 60 * 1000;
|
|
57
|
+
|
|
58
|
+
// Bookkeeping for already-delivered message ids is bounded and expires, so a
|
|
59
|
+
// long-lived main process cannot accumulate state forever.
|
|
60
|
+
const SEEN_TTL_MS = 60 * 60 * 1000;
|
|
61
|
+
const SEEN_MAX_ENTRIES = 4096;
|
|
62
|
+
const SEEN_SWEEP_INTERVAL_MS = 60 * 1000;
|
|
63
|
+
|
|
64
|
+
// Channel-tree janitor (main side). It only removes content nobody can still
|
|
65
|
+
// be waiting for, and only prunes directories that are completely empty.
|
|
66
|
+
const SWEEP_INTERVAL_MS = 60 * 1000;
|
|
67
|
+
/** After the longest possible wait window plus margin, no reply waiter exists. */
|
|
68
|
+
const REPLY_ORPHAN_TTL_MS = (MAX_ROUTE_TIMEOUT_SECONDS + 24 * 60 * 60) * 1000;
|
|
69
|
+
/** Unparseable files and leftover atomic-write temp files get a grace window. */
|
|
70
|
+
const JUNK_FILE_TTL_MS = 60 * 60 * 1000;
|
|
71
|
+
|
|
46
72
|
export interface MessageRequest {
|
|
47
73
|
type: "pi.message.request";
|
|
48
74
|
id: string;
|
|
49
75
|
createdAt: number;
|
|
50
|
-
from: string; // sender: main or a subagent
|
|
51
|
-
to: string; // target: main or
|
|
76
|
+
from: string; // sender: main or a runtime subagent address
|
|
77
|
+
to: string; // target: main, logical name, or runtime address
|
|
52
78
|
content: string;
|
|
53
79
|
expectsReply: boolean;
|
|
54
80
|
expiresAt?: number;
|
|
@@ -65,14 +91,21 @@ export interface MessageReply {
|
|
|
65
91
|
messageId: string;
|
|
66
92
|
content: string;
|
|
67
93
|
timestamp: number;
|
|
94
|
+
/** who wrote the reply (the original request's `to`), when known */
|
|
95
|
+
from?: string;
|
|
68
96
|
}
|
|
69
97
|
|
|
70
98
|
function safeSegment(value: string): string {
|
|
71
|
-
|
|
99
|
+
const safe = value.replace(/[^\w.-]+/g, "_");
|
|
100
|
+
if (safe === "." || safe === "..") return `_${safe.replace(/\./g, "")}`;
|
|
101
|
+
return safe || "_";
|
|
72
102
|
}
|
|
73
103
|
|
|
74
104
|
/** Channel directory of one agent instance */
|
|
75
105
|
export function channelDir(root: string, runId: string, agent: string, childIndex: number): string {
|
|
106
|
+
if (!Number.isInteger(childIndex) || childIndex < 0 || childIndex > 1_000_000) {
|
|
107
|
+
throw new Error(`Invalid child index: ${childIndex}`);
|
|
108
|
+
}
|
|
76
109
|
return path.join(root, safeSegment(runId), safeSegment(agent), String(childIndex));
|
|
77
110
|
}
|
|
78
111
|
|
|
@@ -84,11 +117,202 @@ function replyPath(dir: string, id: string): string {
|
|
|
84
117
|
}
|
|
85
118
|
|
|
86
119
|
export function ensureDir(dir: string): void {
|
|
87
|
-
|
|
120
|
+
const absolute = path.resolve(dir);
|
|
121
|
+
const parsed = path.parse(absolute);
|
|
122
|
+
let current = parsed.root;
|
|
123
|
+
for (const segment of absolute.slice(parsed.root.length).split(path.sep).filter(Boolean)) {
|
|
124
|
+
current = path.join(current, segment);
|
|
125
|
+
try {
|
|
126
|
+
const st = fs.lstatSync(current);
|
|
127
|
+
if (st.isSymbolicLink() || !st.isDirectory()) {
|
|
128
|
+
throw new Error(`Message channel path is not a real directory: ${current}`);
|
|
129
|
+
}
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
132
|
+
fs.mkdirSync(current, { mode: 0o700 });
|
|
133
|
+
const created = fs.lstatSync(current);
|
|
134
|
+
if (created.isSymbolicLink() || !created.isDirectory()) {
|
|
135
|
+
throw new Error(`Message channel path is not a real directory: ${current}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ============================================================================
|
|
142
|
+
// Channel-root hardening
|
|
143
|
+
//
|
|
144
|
+
// The root may live under a predictable /tmp path. A predictable, world
|
|
145
|
+
// readable/writable directory lets another user pre-plant a symlink and
|
|
146
|
+
// capture or tamper with messages. Creation is therefore always private
|
|
147
|
+
// (0700), symlinked or foreign-owned roots are refused, and an existing root
|
|
148
|
+
// owned by us is tightened.
|
|
149
|
+
// ============================================================================
|
|
150
|
+
|
|
151
|
+
function statRootSafe(root: string): fs.Stats | undefined {
|
|
152
|
+
try {
|
|
153
|
+
// lstat: never follow a symlink planted at the root path
|
|
154
|
+
return fs.lstatSync(root);
|
|
155
|
+
} catch {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function rootOwnershipError(root: string): Error | undefined {
|
|
161
|
+
if (typeof process.getuid !== "function") return undefined;
|
|
162
|
+
const st = statRootSafe(root);
|
|
163
|
+
if (st && st.uid !== process.getuid()) {
|
|
164
|
+
return new Error(`Message channel root "${root}" is owned by another user; refusing to use it.`);
|
|
165
|
+
}
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Create/verify the channel root for the main side. Prefer this over a raw
|
|
171
|
+
* `fs.mkdirSync(root, { recursive: true })`: it is private from the start,
|
|
172
|
+
* refuses symlinked or foreign-owned roots, and tightens a pre-existing root.
|
|
173
|
+
*/
|
|
174
|
+
export function ensureChannelRoot(root: string): void {
|
|
175
|
+
const st = statRootSafe(root);
|
|
176
|
+
if (!st) {
|
|
177
|
+
// Fresh creation: mode is capped by the umask, never widened.
|
|
178
|
+
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
179
|
+
const created = statRootSafe(root);
|
|
180
|
+
if (!created || created.isSymbolicLink() || !created.isDirectory()) {
|
|
181
|
+
throw new Error(`Channel root "${root}" could not be created as a real directory.`);
|
|
182
|
+
}
|
|
183
|
+
const ownership = rootOwnershipError(root);
|
|
184
|
+
if (ownership) throw ownership;
|
|
185
|
+
try {
|
|
186
|
+
fs.chmodSync(root, 0o700);
|
|
187
|
+
} catch {
|
|
188
|
+
throw new Error(`Channel root "${root}" could not be locked down.`);
|
|
189
|
+
}
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (st.isSymbolicLink() || !st.isDirectory()) {
|
|
193
|
+
throw new Error(`Channel root "${root}" exists but is not a real directory.`);
|
|
194
|
+
}
|
|
195
|
+
const ownership = rootOwnershipError(root);
|
|
196
|
+
if (ownership) throw ownership;
|
|
197
|
+
if ((st.mode & 0o077) !== 0) {
|
|
198
|
+
try {
|
|
199
|
+
fs.chmodSync(root, 0o700);
|
|
200
|
+
} catch {
|
|
201
|
+
throw new Error(`Channel root "${root}" is group/world accessible and could not be locked down.`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Best-effort root hardening for child processes (the main side owns the root). */
|
|
207
|
+
const hardenedRoots = new Set<string>();
|
|
208
|
+
function hardenChannelRoot(root: string): void {
|
|
209
|
+
if (hardenedRoots.has(root)) return;
|
|
210
|
+
const st = statRootSafe(root);
|
|
211
|
+
if (!st) return; // not created yet; ensureDir creates it privately on first write
|
|
212
|
+
if (st.isSymbolicLink()) {
|
|
213
|
+
throw new Error(`Message channel root "${root}" is a symlink; refusing to use it.`);
|
|
214
|
+
}
|
|
215
|
+
const ownership = rootOwnershipError(root);
|
|
216
|
+
if (ownership) throw ownership;
|
|
217
|
+
if ((st.mode & 0o077) !== 0) {
|
|
218
|
+
try {
|
|
219
|
+
fs.chmodSync(root, 0o700);
|
|
220
|
+
} catch {
|
|
221
|
+
/* best effort: the main side enforces this on its own root */
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
hardenedRoots.add(root);
|
|
88
225
|
}
|
|
89
226
|
|
|
90
227
|
function writeAtomic(filePath: string, data: string): void {
|
|
91
|
-
|
|
228
|
+
// Write to a temp file in the same directory and rename, so concurrent
|
|
229
|
+
// readers never observe a partially written JSON file.
|
|
230
|
+
const tmp = `${filePath}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
|
|
231
|
+
try {
|
|
232
|
+
fs.writeFileSync(tmp, data, { encoding: "utf-8", mode: 0o600 });
|
|
233
|
+
fs.renameSync(tmp, filePath);
|
|
234
|
+
} catch (e) {
|
|
235
|
+
try {
|
|
236
|
+
fs.unlinkSync(tmp);
|
|
237
|
+
} catch {
|
|
238
|
+
/* ignore */
|
|
239
|
+
}
|
|
240
|
+
throw e;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ============================================================================
|
|
245
|
+
// Tolerant filesystem helpers (concurrent cleanup must not break scans)
|
|
246
|
+
// ============================================================================
|
|
247
|
+
|
|
248
|
+
function listDirSafe(dir: string): string[] {
|
|
249
|
+
try {
|
|
250
|
+
return fs.readdirSync(dir);
|
|
251
|
+
} catch {
|
|
252
|
+
return []; // vanished or unreadable between listing levels
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function isDirectorySafe(p: string): boolean {
|
|
257
|
+
try {
|
|
258
|
+
return fs.lstatSync(p).isDirectory();
|
|
259
|
+
} catch {
|
|
260
|
+
return false; // removed concurrently or unreadable
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function fileAgeMs(file: string, now: number): number {
|
|
265
|
+
try {
|
|
266
|
+
return now - fs.statSync(file).mtimeMs;
|
|
267
|
+
} catch {
|
|
268
|
+
return Number.POSITIVE_INFINITY; // already gone: nothing to clean
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function unlinkQuiet(file: string): void {
|
|
273
|
+
try {
|
|
274
|
+
fs.unlinkSync(file);
|
|
275
|
+
} catch {
|
|
276
|
+
/* already removed */
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function pruneEmptyDir(dir: string): void {
|
|
281
|
+
try {
|
|
282
|
+
// rmdirSync only removes empty directories, so this cannot race away
|
|
283
|
+
// files that a live process created in the meantime.
|
|
284
|
+
fs.rmdirSync(dir);
|
|
285
|
+
} catch {
|
|
286
|
+
/* not empty, gone, or not ours */
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function isValidRequest(value: unknown): value is MessageRequest {
|
|
291
|
+
if (!value || typeof value !== "object") return false;
|
|
292
|
+
const req = value as MessageRequest;
|
|
293
|
+
return (
|
|
294
|
+
req.type === "pi.message.request" &&
|
|
295
|
+
typeof req.id === "string" &&
|
|
296
|
+
req.id.length > 0 &&
|
|
297
|
+
typeof req.createdAt === "number" &&
|
|
298
|
+
Number.isFinite(req.createdAt) &&
|
|
299
|
+
typeof req.from === "string" &&
|
|
300
|
+
typeof req.to === "string" &&
|
|
301
|
+
typeof req.content === "string" &&
|
|
302
|
+
typeof req.expectsReply === "boolean" &&
|
|
303
|
+
(req.expiresAt === undefined || (typeof req.expiresAt === "number" && Number.isFinite(req.expiresAt))) &&
|
|
304
|
+
(req.timeoutMs === undefined || (typeof req.timeoutMs === "number" && Number.isFinite(req.timeoutMs) && req.timeoutMs >= 0)) &&
|
|
305
|
+
typeof req.fromRunId === "string" &&
|
|
306
|
+
typeof req.fromAgent === "string" &&
|
|
307
|
+
Number.isInteger(req.fromChildIndex) &&
|
|
308
|
+
req.fromChildIndex >= 0 &&
|
|
309
|
+
req.fromChildIndex <= 1_000_000
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Tool results in this file carry no structured details. */
|
|
314
|
+
function textResult(text: string): { content: [{ type: "text"; text: string }]; details: undefined } {
|
|
315
|
+
return { content: [{ type: "text", text }], details: undefined };
|
|
92
316
|
}
|
|
93
317
|
|
|
94
318
|
// ============================================================================
|
|
@@ -111,15 +335,27 @@ function readClientMeta(): ClientMeta | undefined {
|
|
|
111
335
|
return { root, runId, agent, childIndex: Number(rawIndex) };
|
|
112
336
|
}
|
|
113
337
|
|
|
338
|
+
// Message ids with a live local reply waiter. `read_inbox` must not consume
|
|
339
|
+
// reply files out from under a concurrent send_message(wait=true) call.
|
|
340
|
+
const activeReplyWaits = new Set<string>();
|
|
341
|
+
|
|
114
342
|
function waitForReply(dir: string, messageId: string, deadline: number, signal?: AbortSignal): Promise<string> {
|
|
115
343
|
return new Promise((resolve, reject) => {
|
|
344
|
+
activeReplyWaits.add(messageId);
|
|
116
345
|
const file = replyPath(dir, messageId);
|
|
346
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
347
|
+
const cleanup = () => {
|
|
348
|
+
activeReplyWaits.delete(messageId);
|
|
349
|
+
if (timer) clearTimeout(timer);
|
|
350
|
+
};
|
|
117
351
|
const tick = () => {
|
|
118
352
|
if (signal?.aborted) {
|
|
353
|
+
cleanup();
|
|
119
354
|
reject(new Error("Message wait cancelled."));
|
|
120
355
|
return;
|
|
121
356
|
}
|
|
122
357
|
if (Date.now() > deadline) {
|
|
358
|
+
cleanup();
|
|
123
359
|
reject(new Error("Timed out waiting for reply."));
|
|
124
360
|
return;
|
|
125
361
|
}
|
|
@@ -130,16 +366,17 @@ function waitForReply(dir: string, messageId: string, deadline: number, signal?:
|
|
|
130
366
|
try {
|
|
131
367
|
fs.unlinkSync(file);
|
|
132
368
|
} catch {
|
|
133
|
-
/*
|
|
369
|
+
/* concurrent consumer or already removed */
|
|
134
370
|
}
|
|
371
|
+
cleanup();
|
|
135
372
|
resolve(parsed.content);
|
|
136
373
|
return;
|
|
137
374
|
}
|
|
138
375
|
}
|
|
139
376
|
} catch {
|
|
140
|
-
/*
|
|
377
|
+
/* transient read/parse issue; keep polling */
|
|
141
378
|
}
|
|
142
|
-
setTimeout(tick, REPLY_POLL_MS);
|
|
379
|
+
timer = setTimeout(tick, REPLY_POLL_MS);
|
|
143
380
|
};
|
|
144
381
|
tick();
|
|
145
382
|
});
|
|
@@ -155,6 +392,7 @@ async function sendMessageInternal(
|
|
|
155
392
|
): Promise<{ messageId: string; reply?: string }> {
|
|
156
393
|
const meta = readClientMeta();
|
|
157
394
|
if (!meta) throw new Error("Message channel is not available.");
|
|
395
|
+
hardenChannelRoot(meta.root);
|
|
158
396
|
const dir = channelDir(meta.root, meta.runId, meta.agent, meta.childIndex);
|
|
159
397
|
ensureDir(path.join(dir, REQUESTS_DIR));
|
|
160
398
|
ensureDir(path.join(dir, REPLIES_DIR));
|
|
@@ -186,6 +424,9 @@ async function sendMessageInternal(
|
|
|
186
424
|
const reply = await waitForReply(dir, messageId, deadline, signal);
|
|
187
425
|
return { messageId, reply };
|
|
188
426
|
} catch (e) {
|
|
427
|
+
// The wait failed (timeout/cancel): withdraw the request so the router
|
|
428
|
+
// stops trying to deliver it. A reply written right after this race is
|
|
429
|
+
// cleaned up by the inbox TTL sweep.
|
|
189
430
|
try {
|
|
190
431
|
fs.unlinkSync(requestPath(dir, messageId));
|
|
191
432
|
} catch {
|
|
@@ -197,11 +438,18 @@ async function sendMessageInternal(
|
|
|
197
438
|
|
|
198
439
|
/** Reply to a message (locate the sender from the message, write the reply back) */
|
|
199
440
|
function replyToMessage(root: string, messageId: string, content: string): boolean {
|
|
441
|
+
hardenChannelRoot(root);
|
|
200
442
|
const request = findRequestInTree(root, messageId);
|
|
201
443
|
if (!request) return false;
|
|
202
444
|
const senderDir = channelDir(root, request.fromRunId, request.fromAgent, request.fromChildIndex);
|
|
203
445
|
ensureDir(path.join(senderDir, REPLIES_DIR));
|
|
204
|
-
const reply: MessageReply = {
|
|
446
|
+
const reply: MessageReply = {
|
|
447
|
+
type: "pi.message.reply",
|
|
448
|
+
messageId,
|
|
449
|
+
content,
|
|
450
|
+
timestamp: Date.now(),
|
|
451
|
+
from: request.to,
|
|
452
|
+
};
|
|
205
453
|
writeAtomic(replyPath(senderDir, messageId), JSON.stringify(reply, null, 2));
|
|
206
454
|
// clean up the replied request
|
|
207
455
|
if (request.requestFile) {
|
|
@@ -215,42 +463,135 @@ function replyToMessage(root: string, messageId: string, content: string): boole
|
|
|
215
463
|
}
|
|
216
464
|
|
|
217
465
|
function findRequestInTree(root: string, messageId: string): MessageRequest | undefined {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
466
|
+
if (!isDirectorySafe(root)) return undefined;
|
|
467
|
+
for (const run of listDirSafe(root)) {
|
|
468
|
+
const runDir = path.join(root, run);
|
|
469
|
+
if (!isDirectorySafe(runDir)) continue;
|
|
470
|
+
for (const agent of listDirSafe(runDir)) {
|
|
471
|
+
const agentDir = path.join(runDir, agent);
|
|
472
|
+
if (!isDirectorySafe(agentDir)) continue;
|
|
473
|
+
for (const idx of listDirSafe(agentDir)) {
|
|
474
|
+
const reqDir = path.join(agentDir, idx, REQUESTS_DIR);
|
|
475
|
+
if (!isDirectorySafe(reqDir)) continue;
|
|
476
|
+
for (const f of listDirSafe(reqDir)) {
|
|
477
|
+
if (f !== `${safeSegment(messageId)}.json`) continue;
|
|
478
|
+
try {
|
|
479
|
+
const requestFile = path.join(reqDir, f);
|
|
480
|
+
const req = JSON.parse(fs.readFileSync(requestFile, "utf-8")) as MessageRequest;
|
|
481
|
+
if (isValidRequest(req)) {
|
|
482
|
+
req.requestFile = requestFile;
|
|
483
|
+
return req;
|
|
235
484
|
}
|
|
485
|
+
} catch {
|
|
486
|
+
/* torn or concurrently removed file */
|
|
236
487
|
}
|
|
237
488
|
}
|
|
238
489
|
}
|
|
239
490
|
}
|
|
240
|
-
} catch {
|
|
241
|
-
/* ignore */
|
|
242
491
|
}
|
|
243
492
|
return undefined;
|
|
244
493
|
}
|
|
245
494
|
|
|
495
|
+
// ============================================================================
|
|
496
|
+
// Channel tree janitor (main side)
|
|
497
|
+
// ============================================================================
|
|
498
|
+
|
|
499
|
+
function isDeliverableRequestFile(file: string): boolean {
|
|
500
|
+
try {
|
|
501
|
+
return isValidRequest(JSON.parse(fs.readFileSync(file, "utf-8")));
|
|
502
|
+
} catch {
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function sweepChannelDir(idxDir: string, now: number): void {
|
|
508
|
+
const reqDir = path.join(idxDir, REQUESTS_DIR);
|
|
509
|
+
for (const f of listDirSafe(reqDir)) {
|
|
510
|
+
const file = path.join(reqDir, f);
|
|
511
|
+
if (f.endsWith(".tmp")) {
|
|
512
|
+
// leftover from a crashed atomic write
|
|
513
|
+
if (fileAgeMs(file, now) > JUNK_FILE_TTL_MS) unlinkQuiet(file);
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
if (!f.endsWith(".json")) continue;
|
|
517
|
+
// Keep live requests, but clean expired or abandoned requests even when
|
|
518
|
+
// their session is no longer active and no router will see them.
|
|
519
|
+
if (isDeliverableRequestFile(file)) {
|
|
520
|
+
try {
|
|
521
|
+
const req = JSON.parse(fs.readFileSync(file, "utf-8")) as MessageRequest;
|
|
522
|
+
const abandoned = req.expiresAt !== undefined
|
|
523
|
+
? req.expiresAt < now
|
|
524
|
+
: now - req.createdAt > REPLY_ORPHAN_TTL_MS;
|
|
525
|
+
if (!abandoned) continue;
|
|
526
|
+
unlinkQuiet(file);
|
|
527
|
+
continue;
|
|
528
|
+
} catch {
|
|
529
|
+
/* fall through to stale-file cleanup */
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (fileAgeMs(file, now) > JUNK_FILE_TTL_MS) unlinkQuiet(file);
|
|
533
|
+
}
|
|
534
|
+
const repliesDir = path.join(idxDir, REPLIES_DIR);
|
|
535
|
+
for (const f of listDirSafe(repliesDir)) {
|
|
536
|
+
const file = path.join(repliesDir, f);
|
|
537
|
+
if (f.endsWith(".tmp")) {
|
|
538
|
+
if (fileAgeMs(file, now) > JUNK_FILE_TTL_MS) unlinkQuiet(file);
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
if (!f.endsWith(".json")) continue;
|
|
542
|
+
// Unconsumed replies: once the longest possible wait window plus margin
|
|
543
|
+
// has passed, no waiter can still be polling for them.
|
|
544
|
+
if (fileAgeMs(file, now) > REPLY_ORPHAN_TTL_MS) unlinkQuiet(file);
|
|
545
|
+
}
|
|
546
|
+
for (const f of listDirSafe(idxDir)) {
|
|
547
|
+
if (f === REQUESTS_DIR || f === REPLIES_DIR) continue;
|
|
548
|
+
const file = path.join(idxDir, f);
|
|
549
|
+
if (!isDirectorySafe(file) && fileAgeMs(file, now) > JUNK_FILE_TTL_MS) unlinkQuiet(file);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function sweepChannelTree(root: string, now: number): void {
|
|
554
|
+
if (!isDirectorySafe(root)) return;
|
|
555
|
+
for (const run of listDirSafe(root)) {
|
|
556
|
+
const runDir = path.join(root, run);
|
|
557
|
+
if (!isDirectorySafe(runDir)) continue;
|
|
558
|
+
for (const agent of listDirSafe(runDir)) {
|
|
559
|
+
const agentDir = path.join(runDir, agent);
|
|
560
|
+
if (!isDirectorySafe(agentDir)) continue;
|
|
561
|
+
for (const idx of listDirSafe(agentDir)) {
|
|
562
|
+
const idxDir = path.join(agentDir, idx);
|
|
563
|
+
if (!isDirectorySafe(idxDir)) continue;
|
|
564
|
+
sweepChannelDir(idxDir, now);
|
|
565
|
+
pruneEmptyDir(path.join(idxDir, REQUESTS_DIR));
|
|
566
|
+
pruneEmptyDir(path.join(idxDir, REPLIES_DIR));
|
|
567
|
+
pruneEmptyDir(idxDir);
|
|
568
|
+
pruneEmptyDir(agentDir);
|
|
569
|
+
}
|
|
570
|
+
pruneEmptyDir(runDir);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Sweep all session roots below a private base directory. */
|
|
576
|
+
export function sweepMessageRoots(base: string, now = Date.now()): void {
|
|
577
|
+
if (!isDirectorySafe(base)) return;
|
|
578
|
+
for (const session of listDirSafe(base)) {
|
|
579
|
+
const root = path.join(base, session);
|
|
580
|
+
if (!isDirectorySafe(root)) continue;
|
|
581
|
+
sweepChannelTree(root, now);
|
|
582
|
+
pruneEmptyDir(root);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
246
586
|
/** Register the generic messaging tools for a child (subagent) */
|
|
247
587
|
export function registerChildMessaging(pi: ExtensionAPI): void {
|
|
248
588
|
pi.registerTool({
|
|
249
589
|
name: TOOL_SEND,
|
|
250
590
|
label: "Send Message",
|
|
251
591
|
description: [
|
|
252
|
-
"Send a message to any target: to='main' reaches the main agent
|
|
592
|
+
"Send a message to any target: to='main' reaches the main agent; use a logical agent name or an exact runtime address such as actor#01ab23cd for a subagent.",
|
|
253
593
|
"wait=true (ask): block until the other party replies before continuing; wait=false (send): fire and forget.",
|
|
594
|
+
"When running as a synchronous task, contact main with wait=false because main is waiting for the task to finish.",
|
|
254
595
|
"Use it to ask/confirm with the main agent or another subagent instead of leaving notes in normal output.",
|
|
255
596
|
].join(" "),
|
|
256
597
|
parameters: Type.Object({
|
|
@@ -282,15 +623,11 @@ export function registerChildMessaging(pi: ExtensionAPI): void {
|
|
|
282
623
|
signal,
|
|
283
624
|
);
|
|
284
625
|
if (reply !== undefined) {
|
|
285
|
-
return {
|
|
286
|
-
content: [{ type: "text", text: `Reply from ${params.to}:\n${reply}` }],
|
|
287
|
-
};
|
|
626
|
+
return textResult(`Reply from ${params.to}:\n${reply}`);
|
|
288
627
|
}
|
|
289
|
-
return
|
|
628
|
+
return textResult(`Sent message to ${params.to} (id ${messageId.slice(0, 8)})`);
|
|
290
629
|
} catch (e) {
|
|
291
|
-
return {
|
|
292
|
-
content: [{ type: "text", text: `Failed to send message: ${e instanceof Error ? e.message : String(e)}` }],
|
|
293
|
-
};
|
|
630
|
+
return textResult(`Failed to send message: ${e instanceof Error ? e.message : String(e)}`);
|
|
294
631
|
}
|
|
295
632
|
},
|
|
296
633
|
});
|
|
@@ -302,21 +639,12 @@ export function registerChildMessaging(pi: ExtensionAPI): void {
|
|
|
302
639
|
parameters: Type.Object({}),
|
|
303
640
|
execute: async (_id) => {
|
|
304
641
|
const meta = readClientMeta();
|
|
305
|
-
if (!meta) return
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const lines = files.map((f) => {
|
|
312
|
-
try {
|
|
313
|
-
const r = JSON.parse(fs.readFileSync(path.join(reqDir, f), "utf-8")) as MessageRequest;
|
|
314
|
-
return `- [${r.id.slice(0, 8)}] from ${r.from}: ${r.content.slice(0, 150)}`;
|
|
315
|
-
} catch {
|
|
316
|
-
return "";
|
|
317
|
-
}
|
|
318
|
-
});
|
|
319
|
-
return { content: [{ type: "text", text: `Inbox (requests from other agents):\n${lines.join("\n")}` }] };
|
|
642
|
+
if (!meta) return textResult("Message channel is not available.");
|
|
643
|
+
try {
|
|
644
|
+
return textResult(readInbox(meta));
|
|
645
|
+
} catch (e) {
|
|
646
|
+
return textResult(`Failed to read inbox: ${e instanceof Error ? e.message : String(e)}`);
|
|
647
|
+
}
|
|
320
648
|
},
|
|
321
649
|
});
|
|
322
650
|
|
|
@@ -330,15 +658,71 @@ export function registerChildMessaging(pi: ExtensionAPI): void {
|
|
|
330
658
|
}),
|
|
331
659
|
execute: async (_id, params) => {
|
|
332
660
|
const meta = readClientMeta();
|
|
333
|
-
if (!meta) return
|
|
661
|
+
if (!meta) return textResult("Message channel is not available.");
|
|
662
|
+
try {
|
|
663
|
+
hardenChannelRoot(meta.root);
|
|
664
|
+
} catch (e) {
|
|
665
|
+
return textResult(`Message channel is not available: ${e instanceof Error ? e.message : String(e)}`);
|
|
666
|
+
}
|
|
334
667
|
if (!replyToMessage(meta.root, params.message_id, params.content)) {
|
|
335
|
-
return
|
|
668
|
+
return textResult(`Message not found: ${params.message_id.slice(0, 8)}`);
|
|
336
669
|
}
|
|
337
|
-
return
|
|
670
|
+
return textResult(`Replied to ${params.message_id.slice(0, 8)}`);
|
|
338
671
|
},
|
|
339
672
|
});
|
|
340
673
|
}
|
|
341
674
|
|
|
675
|
+
interface InboxEntry {
|
|
676
|
+
sortKey: number;
|
|
677
|
+
text: string;
|
|
678
|
+
file?: string; // consumed (removed) after being reported
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* A channel's inbound mail is the replies written back into this channel.
|
|
683
|
+
* Request files are the sender's outbox and are consumed by the main router;
|
|
684
|
+
* they are intentionally not exposed here. Reply files are consumed after
|
|
685
|
+
* reporting so reads do not repeat; files that a concurrent send_message
|
|
686
|
+
* (wait=true) is polling are left untouched.
|
|
687
|
+
*/
|
|
688
|
+
function readInbox(meta: ClientMeta): string {
|
|
689
|
+
hardenChannelRoot(meta.root);
|
|
690
|
+
const dir = channelDir(meta.root, meta.runId, meta.agent, meta.childIndex);
|
|
691
|
+
const entries: InboxEntry[] = [];
|
|
692
|
+
|
|
693
|
+
// 1) replies addressed to this channel
|
|
694
|
+
const repliesDir = path.join(dir, REPLIES_DIR);
|
|
695
|
+
for (const f of listDirSafe(repliesDir)) {
|
|
696
|
+
if (!f.endsWith(".json")) continue;
|
|
697
|
+
const id = f.slice(0, -".json".length);
|
|
698
|
+
// A live wait owns this reply; let waitForReply consume it.
|
|
699
|
+
if (activeReplyWaits.has(id)) continue;
|
|
700
|
+
const file = path.join(repliesDir, f);
|
|
701
|
+
try {
|
|
702
|
+
const reply = JSON.parse(fs.readFileSync(file, "utf-8")) as MessageReply;
|
|
703
|
+
if (reply.type !== "pi.message.reply" || typeof reply.content !== "string") throw new Error("malformed reply");
|
|
704
|
+
entries.push({
|
|
705
|
+
sortKey: typeof reply.timestamp === "number" ? reply.timestamp : 0,
|
|
706
|
+
text: `- [${id.slice(0, 8)}] reply from ${reply.from ?? "unknown"} at ${new Date(
|
|
707
|
+
typeof reply.timestamp === "number" ? reply.timestamp : Date.now(),
|
|
708
|
+
).toISOString()}:\n${reply.content}`,
|
|
709
|
+
file,
|
|
710
|
+
});
|
|
711
|
+
} catch {
|
|
712
|
+
// torn/corrupt file: drop it so it cannot poison every future read
|
|
713
|
+
unlinkQuiet(file);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (entries.length === 0) return "Inbox is empty.";
|
|
718
|
+
entries.sort((a, b) => a.sortKey - b.sortKey);
|
|
719
|
+
// Consume reported replies only after they were read successfully.
|
|
720
|
+
for (const entry of entries) {
|
|
721
|
+
if (entry.file) unlinkQuiet(entry.file);
|
|
722
|
+
}
|
|
723
|
+
return `Inbox (${entries.length} message${entries.length === 1 ? "" : "s"}):\n${entries.map((e) => e.text).join("\n")}`;
|
|
724
|
+
}
|
|
725
|
+
|
|
342
726
|
// ============================================================================
|
|
343
727
|
// Main-agent side: message router (poller)
|
|
344
728
|
// ============================================================================
|
|
@@ -353,6 +737,8 @@ export interface MessageRouterState {
|
|
|
353
737
|
/** notification hooks used by the main-side wait graph */
|
|
354
738
|
onMessageReplied?: (msg: MessageRequest) => void;
|
|
355
739
|
onMessageExpired?: (msg: MessageRequest) => void;
|
|
740
|
+
/** a message was permanently dropped (expired or exhausted delivery retries) */
|
|
741
|
+
onMessageDropped?: (msg: MessageRequest) => void;
|
|
356
742
|
}
|
|
357
743
|
|
|
358
744
|
export interface MessageRouter {
|
|
@@ -363,41 +749,37 @@ export interface MessageRouter {
|
|
|
363
749
|
/** Scan the channel tree for all pending message requests */
|
|
364
750
|
export function scanMessages(root: string): MessageRequest[] {
|
|
365
751
|
const out: MessageRequest[] = [];
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
out.push(req);
|
|
384
|
-
}
|
|
385
|
-
} catch {
|
|
386
|
-
/* ignore */
|
|
752
|
+
if (!isDirectorySafe(root)) return out;
|
|
753
|
+
for (const run of listDirSafe(root)) {
|
|
754
|
+
const runDir = path.join(root, run);
|
|
755
|
+
if (!isDirectorySafe(runDir)) continue;
|
|
756
|
+
for (const agent of listDirSafe(runDir)) {
|
|
757
|
+
const agentDir = path.join(runDir, agent);
|
|
758
|
+
if (!isDirectorySafe(agentDir)) continue;
|
|
759
|
+
for (const idx of listDirSafe(agentDir)) {
|
|
760
|
+
const reqDir = path.join(agentDir, idx, REQUESTS_DIR);
|
|
761
|
+
if (!isDirectorySafe(reqDir)) continue;
|
|
762
|
+
for (const f of listDirSafe(reqDir)) {
|
|
763
|
+
if (!f.endsWith(".json")) continue;
|
|
764
|
+
try {
|
|
765
|
+
const req = JSON.parse(fs.readFileSync(path.join(reqDir, f), "utf-8")) as MessageRequest;
|
|
766
|
+
if (isValidRequest(req)) {
|
|
767
|
+
req.requestFile = path.join(reqDir, f);
|
|
768
|
+
out.push(req);
|
|
387
769
|
}
|
|
770
|
+
} catch {
|
|
771
|
+
/* torn or concurrently removed file: skip only this file */
|
|
388
772
|
}
|
|
389
773
|
}
|
|
390
774
|
}
|
|
391
775
|
}
|
|
392
|
-
} catch {
|
|
393
|
-
/* ignore */
|
|
394
776
|
}
|
|
395
777
|
return out;
|
|
396
778
|
}
|
|
397
779
|
|
|
398
|
-
export function writeReply(dir: string, messageId: string, content: string): void {
|
|
780
|
+
export function writeReply(dir: string, messageId: string, content: string, from?: string): void {
|
|
399
781
|
ensureDir(path.join(dir, REPLIES_DIR));
|
|
400
|
-
const reply: MessageReply = { type: "pi.message.reply", messageId, content, timestamp: Date.now() };
|
|
782
|
+
const reply: MessageReply = { type: "pi.message.reply", messageId, content, timestamp: Date.now(), ...(from ? { from } : {}) };
|
|
401
783
|
writeAtomic(replyPath(dir, messageId), JSON.stringify(reply, null, 2));
|
|
402
784
|
}
|
|
403
785
|
|
|
@@ -409,45 +791,158 @@ export function removeRequestFile(msg: MessageRequest): void {
|
|
|
409
791
|
}
|
|
410
792
|
}
|
|
411
793
|
|
|
794
|
+
/** Per-message router bookkeeping (replaces unbounded seen/inFlight sets) */
|
|
795
|
+
interface RouteState {
|
|
796
|
+
attempts: number;
|
|
797
|
+
/** earliest wall-clock time the next delivery attempt may start */
|
|
798
|
+
nextAttemptAt: number;
|
|
799
|
+
inFlight: boolean;
|
|
800
|
+
/** set once delivery succeeded; kept for TTL/bounded dedup */
|
|
801
|
+
deliveredAt?: number;
|
|
802
|
+
/** Path is retained so delivered requests waiting for a reply stay deduped. */
|
|
803
|
+
requestFile?: string;
|
|
804
|
+
/** expiry handled; suppresses further delivery, seen marking, double notifies */
|
|
805
|
+
expired?: boolean;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** Retry-state entries are garbage once their backoff window is long past. */
|
|
809
|
+
const RETRY_ENTRY_TTL_MS = 10 * 60 * 1000;
|
|
810
|
+
|
|
811
|
+
function sweepRouteStates(routes: Map<string, RouteState>, now: number): void {
|
|
812
|
+
// TTL: delivered ids only need to be remembered until their request file is
|
|
813
|
+
// gone (handlers remove it); while the file lingers the entry keeps deduping
|
|
814
|
+
// it. Entries waiting for a retry expire too: an entry whose backoff window
|
|
815
|
+
// passed long ago without an attempt means its request file has left the
|
|
816
|
+
// scan (replied, removed, filtered), so the state is garbage.
|
|
817
|
+
for (const [id, st] of routes) {
|
|
818
|
+
if (st.deliveredAt !== undefined) {
|
|
819
|
+
if (
|
|
820
|
+
(!st.requestFile || !fs.existsSync(st.requestFile)) &&
|
|
821
|
+
now - st.deliveredAt > SEEN_TTL_MS
|
|
822
|
+
) {
|
|
823
|
+
routes.delete(id);
|
|
824
|
+
}
|
|
825
|
+
} else if (!st.inFlight && now - st.nextAttemptAt > RETRY_ENTRY_TTL_MS) {
|
|
826
|
+
routes.delete(id);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
// Bound: evict oldest entries (Map preserves insertion order), never ones
|
|
830
|
+
// whose delivery is still running.
|
|
831
|
+
let overflow = routes.size - SEEN_MAX_ENTRIES;
|
|
832
|
+
if (overflow > 0) {
|
|
833
|
+
for (const [id, st] of routes) {
|
|
834
|
+
if (st.inFlight) continue;
|
|
835
|
+
if (st.deliveredAt !== undefined && st.requestFile && fs.existsSync(st.requestFile)) continue;
|
|
836
|
+
routes.delete(id);
|
|
837
|
+
if (--overflow <= 0) break;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
412
842
|
/** Main agent creates a message router: poller scans, then splits main vs subagent */
|
|
413
843
|
export function createMessageRouter(pi: ExtensionAPI, state: MessageRouterState): MessageRouter {
|
|
414
|
-
const
|
|
415
|
-
const inFlight = new Set<string>();
|
|
844
|
+
const routes = new Map<string, RouteState>();
|
|
416
845
|
let poller: ReturnType<typeof setInterval> | undefined;
|
|
846
|
+
let lastSweepAt = 0;
|
|
847
|
+
|
|
848
|
+
const notifyDropped = (msg: MessageRequest): void => {
|
|
849
|
+
(state.onMessageDropped ?? state.onMessageExpired)?.(msg);
|
|
850
|
+
};
|
|
851
|
+
|
|
852
|
+
/** Record a failed delivery attempt; drop the message after too many tries. */
|
|
853
|
+
const failDelivery = (msg: MessageRequest, entry: RouteState, now: number): void => {
|
|
854
|
+
entry.attempts += 1;
|
|
855
|
+
if (entry.attempts >= MAX_DELIVERY_ATTEMPTS) {
|
|
856
|
+
entry.expired = true;
|
|
857
|
+
routes.delete(msg.id);
|
|
858
|
+
notifyDropped(msg);
|
|
859
|
+
if (msg.expectsReply) {
|
|
860
|
+
try {
|
|
861
|
+
const senderDir = channelDir(state.root, msg.fromRunId, msg.fromAgent, msg.fromChildIndex);
|
|
862
|
+
writeReply(senderDir, msg.id, `[delivery error] Message could not be delivered after ${entry.attempts} attempts.`);
|
|
863
|
+
} catch {
|
|
864
|
+
/* best effort; the sender will eventually time out */
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
removeRequestFile(msg);
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
const backoff = Math.min(RETRY_BACKOFF_MAX_MS, RETRY_BACKOFF_BASE_MS * 2 ** (entry.attempts - 1));
|
|
871
|
+
entry.nextAttemptAt = now + backoff;
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
const expireMessage = (msg: MessageRequest, st: RouteState | undefined): void => {
|
|
875
|
+
if (!st) {
|
|
876
|
+
state.onMessageExpired?.(msg);
|
|
877
|
+
removeRequestFile(msg);
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
// Notify at most once, even if a delivery is still in flight; the
|
|
881
|
+
// running delivery keeps owning the entry until it settles.
|
|
882
|
+
if (!st.expired) {
|
|
883
|
+
st.expired = true;
|
|
884
|
+
state.onMessageExpired?.(msg);
|
|
885
|
+
removeRequestFile(msg);
|
|
886
|
+
}
|
|
887
|
+
if (!st.inFlight) routes.delete(msg.id);
|
|
888
|
+
};
|
|
417
889
|
|
|
418
890
|
const poll = () => {
|
|
891
|
+
const now = Date.now();
|
|
892
|
+
if (now - lastSweepAt >= SWEEP_INTERVAL_MS) {
|
|
893
|
+
lastSweepAt = now;
|
|
894
|
+
sweepRouteStates(routes, now);
|
|
895
|
+
try {
|
|
896
|
+
sweepChannelTree(state.root, now);
|
|
897
|
+
} catch {
|
|
898
|
+
/* janitor failures must never break routing */
|
|
899
|
+
}
|
|
900
|
+
}
|
|
419
901
|
for (const msg of scanMessages(state.root)) {
|
|
420
|
-
if (msg.expiresAt !== undefined && msg.expiresAt <
|
|
421
|
-
|
|
422
|
-
inFlight.delete(msg.id);
|
|
423
|
-
state.onMessageExpired?.(msg);
|
|
424
|
-
removeRequestFile(msg);
|
|
902
|
+
if (msg.expiresAt !== undefined && msg.expiresAt < now) {
|
|
903
|
+
expireMessage(msg, routes.get(msg.id));
|
|
425
904
|
continue;
|
|
426
905
|
}
|
|
427
|
-
|
|
906
|
+
const st = routes.get(msg.id);
|
|
907
|
+
if (st?.inFlight) continue;
|
|
908
|
+
if (st?.deliveredAt !== undefined) continue;
|
|
909
|
+
if (st && now < st.nextAttemptAt) continue;
|
|
428
910
|
if (!state.matchesContext(msg)) continue;
|
|
911
|
+
const entry: RouteState = st ?? { attempts: 0, nextAttemptAt: 0, inFlight: false };
|
|
912
|
+
entry.requestFile = msg.requestFile;
|
|
913
|
+
routes.set(msg.id, entry);
|
|
429
914
|
if (msg.to === MAIN_AGENT) {
|
|
430
915
|
try {
|
|
431
916
|
state.onMainMessage(msg);
|
|
432
917
|
// A request to main remains on disk until reply_message is
|
|
433
918
|
// called, so remember successful delivery to avoid injecting
|
|
434
|
-
// it on every poll. Failed delivery is
|
|
435
|
-
|
|
919
|
+
// it on every poll. Failed delivery is never marked seen; it
|
|
920
|
+
// retries with backoff and is dropped after too many tries.
|
|
921
|
+
entry.deliveredAt = now;
|
|
436
922
|
} catch {
|
|
437
|
-
|
|
923
|
+
failDelivery(msg, entry, now);
|
|
438
924
|
}
|
|
439
925
|
} else {
|
|
440
|
-
inFlight
|
|
926
|
+
entry.inFlight = true;
|
|
441
927
|
void Promise.resolve()
|
|
442
928
|
.then(() => state.onChildMessage(msg))
|
|
443
929
|
.then(() => {
|
|
444
|
-
|
|
930
|
+
// Do not mark delivered when the message expired while the
|
|
931
|
+
// delivery was running: its request file is already gone.
|
|
932
|
+
if (!entry.expired) entry.deliveredAt = Date.now();
|
|
445
933
|
})
|
|
446
934
|
.catch(() => {
|
|
447
|
-
|
|
935
|
+
// Delivery failed: never mark seen. Retry with backoff, or
|
|
936
|
+
// drop (and release waiters) once attempts are exhausted.
|
|
937
|
+
if (!entry.expired) failDelivery(msg, entry, Date.now());
|
|
448
938
|
})
|
|
449
939
|
.finally(() => {
|
|
450
|
-
inFlight
|
|
940
|
+
entry.inFlight = false;
|
|
941
|
+
// Delivered entries stay for TTL/bounded dedup and retrying
|
|
942
|
+
// entries keep their attempts/backoff state (deleting them
|
|
943
|
+
// would reset the backoff into a tight retry storm). Only
|
|
944
|
+
// expiry leaves no residue: its request file is already gone.
|
|
945
|
+
if (entry.expired && entry.deliveredAt === undefined) routes.delete(msg.id);
|
|
451
946
|
});
|
|
452
947
|
}
|
|
453
948
|
}
|
|
@@ -475,8 +970,8 @@ function registerMainReplyTool(pi: ExtensionAPI, state: MessageRouterState): voi
|
|
|
475
970
|
name: TOOL_SEND,
|
|
476
971
|
label: "Send Message",
|
|
477
972
|
description: [
|
|
478
|
-
"Send a message to a subagent: to=<
|
|
479
|
-
"The subagent processes the message and replies via reply_message.",
|
|
973
|
+
"Send a message to a subagent: to=<logical name> or an exact runtime address such as actor#01ab23cd. wait=true waits for its reply.",
|
|
974
|
+
"The subagent processes the message and replies via reply_message. During a synchronous task, use wait=false when contacting main.",
|
|
480
975
|
].join(" "),
|
|
481
976
|
parameters: Type.Object({
|
|
482
977
|
to: Type.String({ description: "Subagent name" }),
|
|
@@ -500,22 +995,20 @@ function registerMainReplyTool(pi: ExtensionAPI, state: MessageRouterState): voi
|
|
|
500
995
|
) * 1000;
|
|
501
996
|
const msg = findOrCreateProxyRequest(state.root, params.content, wait, timeoutMs);
|
|
502
997
|
if (!msg) {
|
|
503
|
-
return
|
|
998
|
+
return textResult("Message channel is not ready.");
|
|
504
999
|
}
|
|
505
1000
|
const routed = { ...msg, to: params.to };
|
|
506
1001
|
if (!wait) {
|
|
507
1002
|
void Promise.resolve(state.onChildMessage(routed)).catch(() => {
|
|
508
1003
|
/* fire-and-forget failures cannot be returned to this completed tool call */
|
|
509
1004
|
});
|
|
510
|
-
return
|
|
1005
|
+
return textResult(`Sent message to ${params.to}`);
|
|
511
1006
|
}
|
|
512
1007
|
try {
|
|
513
1008
|
const reply = await state.onChildMessage(routed, signal);
|
|
514
|
-
return
|
|
1009
|
+
return textResult(`Reply from ${params.to}:\n${reply}`);
|
|
515
1010
|
} catch (e) {
|
|
516
|
-
return {
|
|
517
|
-
content: [{ type: "text", text: `Failed to message ${params.to}: ${e instanceof Error ? e.message : String(e)}` }],
|
|
518
|
-
};
|
|
1011
|
+
return textResult(`Failed to message ${params.to}: ${e instanceof Error ? e.message : String(e)}`);
|
|
519
1012
|
}
|
|
520
1013
|
},
|
|
521
1014
|
});
|
|
@@ -534,15 +1027,13 @@ function registerMainReplyTool(pi: ExtensionAPI, state: MessageRouterState): voi
|
|
|
534
1027
|
execute: async (_id, params) => {
|
|
535
1028
|
const msg = findRequestInTree(state.root, params.message_id);
|
|
536
1029
|
if (!msg) {
|
|
537
|
-
return {
|
|
538
|
-
content: [{ type: "text", text: `Message not found: ${params.message_id.slice(0, 8)} (may already be handled)` }],
|
|
539
|
-
};
|
|
1030
|
+
return textResult(`Message not found: ${params.message_id.slice(0, 8)} (may already be handled)`);
|
|
540
1031
|
}
|
|
541
1032
|
const dir = channelDir(state.root, msg.fromRunId, msg.fromAgent, msg.fromChildIndex);
|
|
542
|
-
writeReply(dir, msg.id, params.content);
|
|
1033
|
+
writeReply(dir, msg.id, params.content, msg.to);
|
|
543
1034
|
removeRequestFile(msg);
|
|
544
1035
|
state.onMessageReplied?.(msg);
|
|
545
|
-
return
|
|
1036
|
+
return textResult(`Replied to ${msg.from}`);
|
|
546
1037
|
},
|
|
547
1038
|
});
|
|
548
1039
|
}
|
|
@@ -555,14 +1046,16 @@ function findOrCreateProxyRequest(
|
|
|
555
1046
|
timeoutMs: number,
|
|
556
1047
|
): MessageRequest | undefined {
|
|
557
1048
|
if (!root) return undefined;
|
|
1049
|
+
const now = Date.now();
|
|
558
1050
|
return {
|
|
559
1051
|
type: "pi.message.request",
|
|
560
1052
|
id: randomUUID(),
|
|
561
|
-
createdAt:
|
|
1053
|
+
createdAt: now,
|
|
562
1054
|
from: MAIN_AGENT,
|
|
563
1055
|
to: "",
|
|
564
1056
|
content,
|
|
565
1057
|
expectsReply,
|
|
1058
|
+
...(expectsReply ? { expiresAt: now + timeoutMs } : {}),
|
|
566
1059
|
timeoutMs,
|
|
567
1060
|
requestFile: "",
|
|
568
1061
|
fromRunId: "main",
|