pi-agent-squad 0.8.0 → 0.8.3
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 +39 -12
- 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 +425 -125
- package/message.ts +592 -111
- package/orchestrator.md +118 -134
- 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,190 @@ function replyPath(dir: string, id: string): string {
|
|
|
84
117
|
}
|
|
85
118
|
|
|
86
119
|
export function ensureDir(dir: string): void {
|
|
120
|
+
// macOS commonly exposes /var as a system symlink (/private/var), so
|
|
121
|
+
// validating every ancestor would reject otherwise safe channel paths.
|
|
87
122
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
123
|
+
const st = fs.lstatSync(dir);
|
|
124
|
+
if (st.isSymbolicLink() || !st.isDirectory()) {
|
|
125
|
+
throw new Error(`Message channel path is not a real directory: ${dir}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ============================================================================
|
|
130
|
+
// Channel-root hardening
|
|
131
|
+
//
|
|
132
|
+
// The root may live under a predictable /tmp path. A predictable, world
|
|
133
|
+
// readable/writable directory lets another user pre-plant a symlink and
|
|
134
|
+
// capture or tamper with messages. Creation is therefore always private
|
|
135
|
+
// (0700), symlinked or foreign-owned roots are refused, and an existing root
|
|
136
|
+
// owned by us is tightened.
|
|
137
|
+
// ============================================================================
|
|
138
|
+
|
|
139
|
+
function statRootSafe(root: string): fs.Stats | undefined {
|
|
140
|
+
try {
|
|
141
|
+
// lstat: never follow a symlink planted at the root path
|
|
142
|
+
return fs.lstatSync(root);
|
|
143
|
+
} catch {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function rootOwnershipError(root: string): Error | undefined {
|
|
149
|
+
if (typeof process.getuid !== "function") return undefined;
|
|
150
|
+
const st = statRootSafe(root);
|
|
151
|
+
if (st && st.uid !== process.getuid()) {
|
|
152
|
+
return new Error(`Message channel root "${root}" is owned by another user; refusing to use it.`);
|
|
153
|
+
}
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Create/verify the channel root for the main side. Prefer this over a raw
|
|
159
|
+
* `fs.mkdirSync(root, { recursive: true })`: it is private from the start,
|
|
160
|
+
* refuses symlinked or foreign-owned roots, and tightens a pre-existing root.
|
|
161
|
+
*/
|
|
162
|
+
export function ensureChannelRoot(root: string): void {
|
|
163
|
+
const st = statRootSafe(root);
|
|
164
|
+
if (!st) {
|
|
165
|
+
// Fresh creation: mode is capped by the umask, never widened.
|
|
166
|
+
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
167
|
+
const created = statRootSafe(root);
|
|
168
|
+
if (!created || created.isSymbolicLink() || !created.isDirectory()) {
|
|
169
|
+
throw new Error(`Channel root "${root}" could not be created as a real directory.`);
|
|
170
|
+
}
|
|
171
|
+
const ownership = rootOwnershipError(root);
|
|
172
|
+
if (ownership) throw ownership;
|
|
173
|
+
try {
|
|
174
|
+
fs.chmodSync(root, 0o700);
|
|
175
|
+
} catch {
|
|
176
|
+
throw new Error(`Channel root "${root}" could not be locked down.`);
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (st.isSymbolicLink() || !st.isDirectory()) {
|
|
181
|
+
throw new Error(`Channel root "${root}" exists but is not a real directory.`);
|
|
182
|
+
}
|
|
183
|
+
const ownership = rootOwnershipError(root);
|
|
184
|
+
if (ownership) throw ownership;
|
|
185
|
+
if ((st.mode & 0o077) !== 0) {
|
|
186
|
+
try {
|
|
187
|
+
fs.chmodSync(root, 0o700);
|
|
188
|
+
} catch {
|
|
189
|
+
throw new Error(`Channel root "${root}" is group/world accessible and could not be locked down.`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Best-effort root hardening for child processes (the main side owns the root). */
|
|
195
|
+
const hardenedRoots = new Set<string>();
|
|
196
|
+
function hardenChannelRoot(root: string): void {
|
|
197
|
+
if (hardenedRoots.has(root)) return;
|
|
198
|
+
const st = statRootSafe(root);
|
|
199
|
+
if (!st) return; // not created yet; ensureDir creates it privately on first write
|
|
200
|
+
if (st.isSymbolicLink()) {
|
|
201
|
+
throw new Error(`Message channel root "${root}" is a symlink; refusing to use it.`);
|
|
202
|
+
}
|
|
203
|
+
const ownership = rootOwnershipError(root);
|
|
204
|
+
if (ownership) throw ownership;
|
|
205
|
+
if ((st.mode & 0o077) !== 0) {
|
|
206
|
+
try {
|
|
207
|
+
fs.chmodSync(root, 0o700);
|
|
208
|
+
} catch {
|
|
209
|
+
/* best effort: the main side enforces this on its own root */
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
hardenedRoots.add(root);
|
|
88
213
|
}
|
|
89
214
|
|
|
90
215
|
function writeAtomic(filePath: string, data: string): void {
|
|
91
|
-
|
|
216
|
+
// Write to a temp file in the same directory and rename, so concurrent
|
|
217
|
+
// readers never observe a partially written JSON file.
|
|
218
|
+
const tmp = `${filePath}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
|
|
219
|
+
try {
|
|
220
|
+
fs.writeFileSync(tmp, data, { encoding: "utf-8", mode: 0o600 });
|
|
221
|
+
fs.renameSync(tmp, filePath);
|
|
222
|
+
} catch (e) {
|
|
223
|
+
try {
|
|
224
|
+
fs.unlinkSync(tmp);
|
|
225
|
+
} catch {
|
|
226
|
+
/* ignore */
|
|
227
|
+
}
|
|
228
|
+
throw e;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ============================================================================
|
|
233
|
+
// Tolerant filesystem helpers (concurrent cleanup must not break scans)
|
|
234
|
+
// ============================================================================
|
|
235
|
+
|
|
236
|
+
function listDirSafe(dir: string): string[] {
|
|
237
|
+
try {
|
|
238
|
+
return fs.readdirSync(dir);
|
|
239
|
+
} catch {
|
|
240
|
+
return []; // vanished or unreadable between listing levels
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function isDirectorySafe(p: string): boolean {
|
|
245
|
+
try {
|
|
246
|
+
return fs.lstatSync(p).isDirectory();
|
|
247
|
+
} catch {
|
|
248
|
+
return false; // removed concurrently or unreadable
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function fileAgeMs(file: string, now: number): number {
|
|
253
|
+
try {
|
|
254
|
+
return now - fs.statSync(file).mtimeMs;
|
|
255
|
+
} catch {
|
|
256
|
+
return Number.POSITIVE_INFINITY; // already gone: nothing to clean
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function unlinkQuiet(file: string): void {
|
|
261
|
+
try {
|
|
262
|
+
fs.unlinkSync(file);
|
|
263
|
+
} catch {
|
|
264
|
+
/* already removed */
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function pruneEmptyDir(dir: string): void {
|
|
269
|
+
try {
|
|
270
|
+
// rmdirSync only removes empty directories, so this cannot race away
|
|
271
|
+
// files that a live process created in the meantime.
|
|
272
|
+
fs.rmdirSync(dir);
|
|
273
|
+
} catch {
|
|
274
|
+
/* not empty, gone, or not ours */
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function isValidRequest(value: unknown): value is MessageRequest {
|
|
279
|
+
if (!value || typeof value !== "object") return false;
|
|
280
|
+
const req = value as MessageRequest;
|
|
281
|
+
return (
|
|
282
|
+
req.type === "pi.message.request" &&
|
|
283
|
+
typeof req.id === "string" &&
|
|
284
|
+
req.id.length > 0 &&
|
|
285
|
+
typeof req.createdAt === "number" &&
|
|
286
|
+
Number.isFinite(req.createdAt) &&
|
|
287
|
+
typeof req.from === "string" &&
|
|
288
|
+
typeof req.to === "string" &&
|
|
289
|
+
typeof req.content === "string" &&
|
|
290
|
+
typeof req.expectsReply === "boolean" &&
|
|
291
|
+
(req.expiresAt === undefined || (typeof req.expiresAt === "number" && Number.isFinite(req.expiresAt))) &&
|
|
292
|
+
(req.timeoutMs === undefined || (typeof req.timeoutMs === "number" && Number.isFinite(req.timeoutMs) && req.timeoutMs >= 0)) &&
|
|
293
|
+
typeof req.fromRunId === "string" &&
|
|
294
|
+
typeof req.fromAgent === "string" &&
|
|
295
|
+
Number.isInteger(req.fromChildIndex) &&
|
|
296
|
+
req.fromChildIndex >= 0 &&
|
|
297
|
+
req.fromChildIndex <= 1_000_000
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Tool results in this file carry no structured details. */
|
|
302
|
+
function textResult(text: string): { content: [{ type: "text"; text: string }]; details: undefined } {
|
|
303
|
+
return { content: [{ type: "text", text }], details: undefined };
|
|
92
304
|
}
|
|
93
305
|
|
|
94
306
|
// ============================================================================
|
|
@@ -111,15 +323,27 @@ function readClientMeta(): ClientMeta | undefined {
|
|
|
111
323
|
return { root, runId, agent, childIndex: Number(rawIndex) };
|
|
112
324
|
}
|
|
113
325
|
|
|
326
|
+
// Message ids with a live local reply waiter. `read_inbox` must not consume
|
|
327
|
+
// reply files out from under a concurrent send_message(wait=true) call.
|
|
328
|
+
const activeReplyWaits = new Set<string>();
|
|
329
|
+
|
|
114
330
|
function waitForReply(dir: string, messageId: string, deadline: number, signal?: AbortSignal): Promise<string> {
|
|
115
331
|
return new Promise((resolve, reject) => {
|
|
332
|
+
activeReplyWaits.add(messageId);
|
|
116
333
|
const file = replyPath(dir, messageId);
|
|
334
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
335
|
+
const cleanup = () => {
|
|
336
|
+
activeReplyWaits.delete(messageId);
|
|
337
|
+
if (timer) clearTimeout(timer);
|
|
338
|
+
};
|
|
117
339
|
const tick = () => {
|
|
118
340
|
if (signal?.aborted) {
|
|
341
|
+
cleanup();
|
|
119
342
|
reject(new Error("Message wait cancelled."));
|
|
120
343
|
return;
|
|
121
344
|
}
|
|
122
345
|
if (Date.now() > deadline) {
|
|
346
|
+
cleanup();
|
|
123
347
|
reject(new Error("Timed out waiting for reply."));
|
|
124
348
|
return;
|
|
125
349
|
}
|
|
@@ -130,16 +354,17 @@ function waitForReply(dir: string, messageId: string, deadline: number, signal?:
|
|
|
130
354
|
try {
|
|
131
355
|
fs.unlinkSync(file);
|
|
132
356
|
} catch {
|
|
133
|
-
/*
|
|
357
|
+
/* concurrent consumer or already removed */
|
|
134
358
|
}
|
|
359
|
+
cleanup();
|
|
135
360
|
resolve(parsed.content);
|
|
136
361
|
return;
|
|
137
362
|
}
|
|
138
363
|
}
|
|
139
364
|
} catch {
|
|
140
|
-
/*
|
|
365
|
+
/* transient read/parse issue; keep polling */
|
|
141
366
|
}
|
|
142
|
-
setTimeout(tick, REPLY_POLL_MS);
|
|
367
|
+
timer = setTimeout(tick, REPLY_POLL_MS);
|
|
143
368
|
};
|
|
144
369
|
tick();
|
|
145
370
|
});
|
|
@@ -155,6 +380,7 @@ async function sendMessageInternal(
|
|
|
155
380
|
): Promise<{ messageId: string; reply?: string }> {
|
|
156
381
|
const meta = readClientMeta();
|
|
157
382
|
if (!meta) throw new Error("Message channel is not available.");
|
|
383
|
+
hardenChannelRoot(meta.root);
|
|
158
384
|
const dir = channelDir(meta.root, meta.runId, meta.agent, meta.childIndex);
|
|
159
385
|
ensureDir(path.join(dir, REQUESTS_DIR));
|
|
160
386
|
ensureDir(path.join(dir, REPLIES_DIR));
|
|
@@ -186,6 +412,9 @@ async function sendMessageInternal(
|
|
|
186
412
|
const reply = await waitForReply(dir, messageId, deadline, signal);
|
|
187
413
|
return { messageId, reply };
|
|
188
414
|
} catch (e) {
|
|
415
|
+
// The wait failed (timeout/cancel): withdraw the request so the router
|
|
416
|
+
// stops trying to deliver it. A reply written right after this race is
|
|
417
|
+
// cleaned up by the inbox TTL sweep.
|
|
189
418
|
try {
|
|
190
419
|
fs.unlinkSync(requestPath(dir, messageId));
|
|
191
420
|
} catch {
|
|
@@ -197,11 +426,18 @@ async function sendMessageInternal(
|
|
|
197
426
|
|
|
198
427
|
/** Reply to a message (locate the sender from the message, write the reply back) */
|
|
199
428
|
function replyToMessage(root: string, messageId: string, content: string): boolean {
|
|
429
|
+
hardenChannelRoot(root);
|
|
200
430
|
const request = findRequestInTree(root, messageId);
|
|
201
431
|
if (!request) return false;
|
|
202
432
|
const senderDir = channelDir(root, request.fromRunId, request.fromAgent, request.fromChildIndex);
|
|
203
433
|
ensureDir(path.join(senderDir, REPLIES_DIR));
|
|
204
|
-
const reply: MessageReply = {
|
|
434
|
+
const reply: MessageReply = {
|
|
435
|
+
type: "pi.message.reply",
|
|
436
|
+
messageId,
|
|
437
|
+
content,
|
|
438
|
+
timestamp: Date.now(),
|
|
439
|
+
from: request.to,
|
|
440
|
+
};
|
|
205
441
|
writeAtomic(replyPath(senderDir, messageId), JSON.stringify(reply, null, 2));
|
|
206
442
|
// clean up the replied request
|
|
207
443
|
if (request.requestFile) {
|
|
@@ -215,42 +451,135 @@ function replyToMessage(root: string, messageId: string, content: string): boole
|
|
|
215
451
|
}
|
|
216
452
|
|
|
217
453
|
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
|
-
|
|
454
|
+
if (!isDirectorySafe(root)) return undefined;
|
|
455
|
+
for (const run of listDirSafe(root)) {
|
|
456
|
+
const runDir = path.join(root, run);
|
|
457
|
+
if (!isDirectorySafe(runDir)) continue;
|
|
458
|
+
for (const agent of listDirSafe(runDir)) {
|
|
459
|
+
const agentDir = path.join(runDir, agent);
|
|
460
|
+
if (!isDirectorySafe(agentDir)) continue;
|
|
461
|
+
for (const idx of listDirSafe(agentDir)) {
|
|
462
|
+
const reqDir = path.join(agentDir, idx, REQUESTS_DIR);
|
|
463
|
+
if (!isDirectorySafe(reqDir)) continue;
|
|
464
|
+
for (const f of listDirSafe(reqDir)) {
|
|
465
|
+
if (f !== `${safeSegment(messageId)}.json`) continue;
|
|
466
|
+
try {
|
|
467
|
+
const requestFile = path.join(reqDir, f);
|
|
468
|
+
const req = JSON.parse(fs.readFileSync(requestFile, "utf-8")) as MessageRequest;
|
|
469
|
+
if (isValidRequest(req)) {
|
|
470
|
+
req.requestFile = requestFile;
|
|
471
|
+
return req;
|
|
235
472
|
}
|
|
473
|
+
} catch {
|
|
474
|
+
/* torn or concurrently removed file */
|
|
236
475
|
}
|
|
237
476
|
}
|
|
238
477
|
}
|
|
239
478
|
}
|
|
240
|
-
} catch {
|
|
241
|
-
/* ignore */
|
|
242
479
|
}
|
|
243
480
|
return undefined;
|
|
244
481
|
}
|
|
245
482
|
|
|
483
|
+
// ============================================================================
|
|
484
|
+
// Channel tree janitor (main side)
|
|
485
|
+
// ============================================================================
|
|
486
|
+
|
|
487
|
+
function isDeliverableRequestFile(file: string): boolean {
|
|
488
|
+
try {
|
|
489
|
+
return isValidRequest(JSON.parse(fs.readFileSync(file, "utf-8")));
|
|
490
|
+
} catch {
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function sweepChannelDir(idxDir: string, now: number): void {
|
|
496
|
+
const reqDir = path.join(idxDir, REQUESTS_DIR);
|
|
497
|
+
for (const f of listDirSafe(reqDir)) {
|
|
498
|
+
const file = path.join(reqDir, f);
|
|
499
|
+
if (f.endsWith(".tmp")) {
|
|
500
|
+
// leftover from a crashed atomic write
|
|
501
|
+
if (fileAgeMs(file, now) > JUNK_FILE_TTL_MS) unlinkQuiet(file);
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
if (!f.endsWith(".json")) continue;
|
|
505
|
+
// Keep live requests, but clean expired or abandoned requests even when
|
|
506
|
+
// their session is no longer active and no router will see them.
|
|
507
|
+
if (isDeliverableRequestFile(file)) {
|
|
508
|
+
try {
|
|
509
|
+
const req = JSON.parse(fs.readFileSync(file, "utf-8")) as MessageRequest;
|
|
510
|
+
const abandoned = req.expiresAt !== undefined
|
|
511
|
+
? req.expiresAt < now
|
|
512
|
+
: now - req.createdAt > REPLY_ORPHAN_TTL_MS;
|
|
513
|
+
if (!abandoned) continue;
|
|
514
|
+
unlinkQuiet(file);
|
|
515
|
+
continue;
|
|
516
|
+
} catch {
|
|
517
|
+
/* fall through to stale-file cleanup */
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (fileAgeMs(file, now) > JUNK_FILE_TTL_MS) unlinkQuiet(file);
|
|
521
|
+
}
|
|
522
|
+
const repliesDir = path.join(idxDir, REPLIES_DIR);
|
|
523
|
+
for (const f of listDirSafe(repliesDir)) {
|
|
524
|
+
const file = path.join(repliesDir, f);
|
|
525
|
+
if (f.endsWith(".tmp")) {
|
|
526
|
+
if (fileAgeMs(file, now) > JUNK_FILE_TTL_MS) unlinkQuiet(file);
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
if (!f.endsWith(".json")) continue;
|
|
530
|
+
// Unconsumed replies: once the longest possible wait window plus margin
|
|
531
|
+
// has passed, no waiter can still be polling for them.
|
|
532
|
+
if (fileAgeMs(file, now) > REPLY_ORPHAN_TTL_MS) unlinkQuiet(file);
|
|
533
|
+
}
|
|
534
|
+
for (const f of listDirSafe(idxDir)) {
|
|
535
|
+
if (f === REQUESTS_DIR || f === REPLIES_DIR) continue;
|
|
536
|
+
const file = path.join(idxDir, f);
|
|
537
|
+
if (!isDirectorySafe(file) && fileAgeMs(file, now) > JUNK_FILE_TTL_MS) unlinkQuiet(file);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function sweepChannelTree(root: string, now: number): void {
|
|
542
|
+
if (!isDirectorySafe(root)) return;
|
|
543
|
+
for (const run of listDirSafe(root)) {
|
|
544
|
+
const runDir = path.join(root, run);
|
|
545
|
+
if (!isDirectorySafe(runDir)) continue;
|
|
546
|
+
for (const agent of listDirSafe(runDir)) {
|
|
547
|
+
const agentDir = path.join(runDir, agent);
|
|
548
|
+
if (!isDirectorySafe(agentDir)) continue;
|
|
549
|
+
for (const idx of listDirSafe(agentDir)) {
|
|
550
|
+
const idxDir = path.join(agentDir, idx);
|
|
551
|
+
if (!isDirectorySafe(idxDir)) continue;
|
|
552
|
+
sweepChannelDir(idxDir, now);
|
|
553
|
+
pruneEmptyDir(path.join(idxDir, REQUESTS_DIR));
|
|
554
|
+
pruneEmptyDir(path.join(idxDir, REPLIES_DIR));
|
|
555
|
+
pruneEmptyDir(idxDir);
|
|
556
|
+
pruneEmptyDir(agentDir);
|
|
557
|
+
}
|
|
558
|
+
pruneEmptyDir(runDir);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Sweep all session roots below a private base directory. */
|
|
564
|
+
export function sweepMessageRoots(base: string, now = Date.now()): void {
|
|
565
|
+
if (!isDirectorySafe(base)) return;
|
|
566
|
+
for (const session of listDirSafe(base)) {
|
|
567
|
+
const root = path.join(base, session);
|
|
568
|
+
if (!isDirectorySafe(root)) continue;
|
|
569
|
+
sweepChannelTree(root, now);
|
|
570
|
+
pruneEmptyDir(root);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
246
574
|
/** Register the generic messaging tools for a child (subagent) */
|
|
247
575
|
export function registerChildMessaging(pi: ExtensionAPI): void {
|
|
248
576
|
pi.registerTool({
|
|
249
577
|
name: TOOL_SEND,
|
|
250
578
|
label: "Send Message",
|
|
251
579
|
description: [
|
|
252
|
-
"Send a message to any target: to='main' reaches the main agent
|
|
580
|
+
"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
581
|
"wait=true (ask): block until the other party replies before continuing; wait=false (send): fire and forget.",
|
|
582
|
+
"When running as a synchronous task, contact main with wait=false because main is waiting for the task to finish.",
|
|
254
583
|
"Use it to ask/confirm with the main agent or another subagent instead of leaving notes in normal output.",
|
|
255
584
|
].join(" "),
|
|
256
585
|
parameters: Type.Object({
|
|
@@ -282,15 +611,11 @@ export function registerChildMessaging(pi: ExtensionAPI): void {
|
|
|
282
611
|
signal,
|
|
283
612
|
);
|
|
284
613
|
if (reply !== undefined) {
|
|
285
|
-
return {
|
|
286
|
-
content: [{ type: "text", text: `Reply from ${params.to}:\n${reply}` }],
|
|
287
|
-
};
|
|
614
|
+
return textResult(`Reply from ${params.to}:\n${reply}`);
|
|
288
615
|
}
|
|
289
|
-
return
|
|
616
|
+
return textResult(`Sent message to ${params.to} (id ${messageId.slice(0, 8)})`);
|
|
290
617
|
} catch (e) {
|
|
291
|
-
return {
|
|
292
|
-
content: [{ type: "text", text: `Failed to send message: ${e instanceof Error ? e.message : String(e)}` }],
|
|
293
|
-
};
|
|
618
|
+
return textResult(`Failed to send message: ${e instanceof Error ? e.message : String(e)}`);
|
|
294
619
|
}
|
|
295
620
|
},
|
|
296
621
|
});
|
|
@@ -302,21 +627,12 @@ export function registerChildMessaging(pi: ExtensionAPI): void {
|
|
|
302
627
|
parameters: Type.Object({}),
|
|
303
628
|
execute: async (_id) => {
|
|
304
629
|
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")}` }] };
|
|
630
|
+
if (!meta) return textResult("Message channel is not available.");
|
|
631
|
+
try {
|
|
632
|
+
return textResult(readInbox(meta));
|
|
633
|
+
} catch (e) {
|
|
634
|
+
return textResult(`Failed to read inbox: ${e instanceof Error ? e.message : String(e)}`);
|
|
635
|
+
}
|
|
320
636
|
},
|
|
321
637
|
});
|
|
322
638
|
|
|
@@ -330,15 +646,71 @@ export function registerChildMessaging(pi: ExtensionAPI): void {
|
|
|
330
646
|
}),
|
|
331
647
|
execute: async (_id, params) => {
|
|
332
648
|
const meta = readClientMeta();
|
|
333
|
-
if (!meta) return
|
|
649
|
+
if (!meta) return textResult("Message channel is not available.");
|
|
650
|
+
try {
|
|
651
|
+
hardenChannelRoot(meta.root);
|
|
652
|
+
} catch (e) {
|
|
653
|
+
return textResult(`Message channel is not available: ${e instanceof Error ? e.message : String(e)}`);
|
|
654
|
+
}
|
|
334
655
|
if (!replyToMessage(meta.root, params.message_id, params.content)) {
|
|
335
|
-
return
|
|
656
|
+
return textResult(`Message not found: ${params.message_id.slice(0, 8)}`);
|
|
336
657
|
}
|
|
337
|
-
return
|
|
658
|
+
return textResult(`Replied to ${params.message_id.slice(0, 8)}`);
|
|
338
659
|
},
|
|
339
660
|
});
|
|
340
661
|
}
|
|
341
662
|
|
|
663
|
+
interface InboxEntry {
|
|
664
|
+
sortKey: number;
|
|
665
|
+
text: string;
|
|
666
|
+
file?: string; // consumed (removed) after being reported
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* A channel's inbound mail is the replies written back into this channel.
|
|
671
|
+
* Request files are the sender's outbox and are consumed by the main router;
|
|
672
|
+
* they are intentionally not exposed here. Reply files are consumed after
|
|
673
|
+
* reporting so reads do not repeat; files that a concurrent send_message
|
|
674
|
+
* (wait=true) is polling are left untouched.
|
|
675
|
+
*/
|
|
676
|
+
function readInbox(meta: ClientMeta): string {
|
|
677
|
+
hardenChannelRoot(meta.root);
|
|
678
|
+
const dir = channelDir(meta.root, meta.runId, meta.agent, meta.childIndex);
|
|
679
|
+
const entries: InboxEntry[] = [];
|
|
680
|
+
|
|
681
|
+
// 1) replies addressed to this channel
|
|
682
|
+
const repliesDir = path.join(dir, REPLIES_DIR);
|
|
683
|
+
for (const f of listDirSafe(repliesDir)) {
|
|
684
|
+
if (!f.endsWith(".json")) continue;
|
|
685
|
+
const id = f.slice(0, -".json".length);
|
|
686
|
+
// A live wait owns this reply; let waitForReply consume it.
|
|
687
|
+
if (activeReplyWaits.has(id)) continue;
|
|
688
|
+
const file = path.join(repliesDir, f);
|
|
689
|
+
try {
|
|
690
|
+
const reply = JSON.parse(fs.readFileSync(file, "utf-8")) as MessageReply;
|
|
691
|
+
if (reply.type !== "pi.message.reply" || typeof reply.content !== "string") throw new Error("malformed reply");
|
|
692
|
+
entries.push({
|
|
693
|
+
sortKey: typeof reply.timestamp === "number" ? reply.timestamp : 0,
|
|
694
|
+
text: `- [${id.slice(0, 8)}] reply from ${reply.from ?? "unknown"} at ${new Date(
|
|
695
|
+
typeof reply.timestamp === "number" ? reply.timestamp : Date.now(),
|
|
696
|
+
).toISOString()}:\n${reply.content}`,
|
|
697
|
+
file,
|
|
698
|
+
});
|
|
699
|
+
} catch {
|
|
700
|
+
// torn/corrupt file: drop it so it cannot poison every future read
|
|
701
|
+
unlinkQuiet(file);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (entries.length === 0) return "Inbox is empty.";
|
|
706
|
+
entries.sort((a, b) => a.sortKey - b.sortKey);
|
|
707
|
+
// Consume reported replies only after they were read successfully.
|
|
708
|
+
for (const entry of entries) {
|
|
709
|
+
if (entry.file) unlinkQuiet(entry.file);
|
|
710
|
+
}
|
|
711
|
+
return `Inbox (${entries.length} message${entries.length === 1 ? "" : "s"}):\n${entries.map((e) => e.text).join("\n")}`;
|
|
712
|
+
}
|
|
713
|
+
|
|
342
714
|
// ============================================================================
|
|
343
715
|
// Main-agent side: message router (poller)
|
|
344
716
|
// ============================================================================
|
|
@@ -353,6 +725,8 @@ export interface MessageRouterState {
|
|
|
353
725
|
/** notification hooks used by the main-side wait graph */
|
|
354
726
|
onMessageReplied?: (msg: MessageRequest) => void;
|
|
355
727
|
onMessageExpired?: (msg: MessageRequest) => void;
|
|
728
|
+
/** a message was permanently dropped (expired or exhausted delivery retries) */
|
|
729
|
+
onMessageDropped?: (msg: MessageRequest) => void;
|
|
356
730
|
}
|
|
357
731
|
|
|
358
732
|
export interface MessageRouter {
|
|
@@ -363,41 +737,37 @@ export interface MessageRouter {
|
|
|
363
737
|
/** Scan the channel tree for all pending message requests */
|
|
364
738
|
export function scanMessages(root: string): MessageRequest[] {
|
|
365
739
|
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 */
|
|
740
|
+
if (!isDirectorySafe(root)) return out;
|
|
741
|
+
for (const run of listDirSafe(root)) {
|
|
742
|
+
const runDir = path.join(root, run);
|
|
743
|
+
if (!isDirectorySafe(runDir)) continue;
|
|
744
|
+
for (const agent of listDirSafe(runDir)) {
|
|
745
|
+
const agentDir = path.join(runDir, agent);
|
|
746
|
+
if (!isDirectorySafe(agentDir)) continue;
|
|
747
|
+
for (const idx of listDirSafe(agentDir)) {
|
|
748
|
+
const reqDir = path.join(agentDir, idx, REQUESTS_DIR);
|
|
749
|
+
if (!isDirectorySafe(reqDir)) continue;
|
|
750
|
+
for (const f of listDirSafe(reqDir)) {
|
|
751
|
+
if (!f.endsWith(".json")) continue;
|
|
752
|
+
try {
|
|
753
|
+
const req = JSON.parse(fs.readFileSync(path.join(reqDir, f), "utf-8")) as MessageRequest;
|
|
754
|
+
if (isValidRequest(req)) {
|
|
755
|
+
req.requestFile = path.join(reqDir, f);
|
|
756
|
+
out.push(req);
|
|
387
757
|
}
|
|
758
|
+
} catch {
|
|
759
|
+
/* torn or concurrently removed file: skip only this file */
|
|
388
760
|
}
|
|
389
761
|
}
|
|
390
762
|
}
|
|
391
763
|
}
|
|
392
|
-
} catch {
|
|
393
|
-
/* ignore */
|
|
394
764
|
}
|
|
395
765
|
return out;
|
|
396
766
|
}
|
|
397
767
|
|
|
398
|
-
export function writeReply(dir: string, messageId: string, content: string): void {
|
|
768
|
+
export function writeReply(dir: string, messageId: string, content: string, from?: string): void {
|
|
399
769
|
ensureDir(path.join(dir, REPLIES_DIR));
|
|
400
|
-
const reply: MessageReply = { type: "pi.message.reply", messageId, content, timestamp: Date.now() };
|
|
770
|
+
const reply: MessageReply = { type: "pi.message.reply", messageId, content, timestamp: Date.now(), ...(from ? { from } : {}) };
|
|
401
771
|
writeAtomic(replyPath(dir, messageId), JSON.stringify(reply, null, 2));
|
|
402
772
|
}
|
|
403
773
|
|
|
@@ -409,45 +779,158 @@ export function removeRequestFile(msg: MessageRequest): void {
|
|
|
409
779
|
}
|
|
410
780
|
}
|
|
411
781
|
|
|
782
|
+
/** Per-message router bookkeeping (replaces unbounded seen/inFlight sets) */
|
|
783
|
+
interface RouteState {
|
|
784
|
+
attempts: number;
|
|
785
|
+
/** earliest wall-clock time the next delivery attempt may start */
|
|
786
|
+
nextAttemptAt: number;
|
|
787
|
+
inFlight: boolean;
|
|
788
|
+
/** set once delivery succeeded; kept for TTL/bounded dedup */
|
|
789
|
+
deliveredAt?: number;
|
|
790
|
+
/** Path is retained so delivered requests waiting for a reply stay deduped. */
|
|
791
|
+
requestFile?: string;
|
|
792
|
+
/** expiry handled; suppresses further delivery, seen marking, double notifies */
|
|
793
|
+
expired?: boolean;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** Retry-state entries are garbage once their backoff window is long past. */
|
|
797
|
+
const RETRY_ENTRY_TTL_MS = 10 * 60 * 1000;
|
|
798
|
+
|
|
799
|
+
function sweepRouteStates(routes: Map<string, RouteState>, now: number): void {
|
|
800
|
+
// TTL: delivered ids only need to be remembered until their request file is
|
|
801
|
+
// gone (handlers remove it); while the file lingers the entry keeps deduping
|
|
802
|
+
// it. Entries waiting for a retry expire too: an entry whose backoff window
|
|
803
|
+
// passed long ago without an attempt means its request file has left the
|
|
804
|
+
// scan (replied, removed, filtered), so the state is garbage.
|
|
805
|
+
for (const [id, st] of routes) {
|
|
806
|
+
if (st.deliveredAt !== undefined) {
|
|
807
|
+
if (
|
|
808
|
+
(!st.requestFile || !fs.existsSync(st.requestFile)) &&
|
|
809
|
+
now - st.deliveredAt > SEEN_TTL_MS
|
|
810
|
+
) {
|
|
811
|
+
routes.delete(id);
|
|
812
|
+
}
|
|
813
|
+
} else if (!st.inFlight && now - st.nextAttemptAt > RETRY_ENTRY_TTL_MS) {
|
|
814
|
+
routes.delete(id);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
// Bound: evict oldest entries (Map preserves insertion order), never ones
|
|
818
|
+
// whose delivery is still running.
|
|
819
|
+
let overflow = routes.size - SEEN_MAX_ENTRIES;
|
|
820
|
+
if (overflow > 0) {
|
|
821
|
+
for (const [id, st] of routes) {
|
|
822
|
+
if (st.inFlight) continue;
|
|
823
|
+
if (st.deliveredAt !== undefined && st.requestFile && fs.existsSync(st.requestFile)) continue;
|
|
824
|
+
routes.delete(id);
|
|
825
|
+
if (--overflow <= 0) break;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
412
830
|
/** Main agent creates a message router: poller scans, then splits main vs subagent */
|
|
413
831
|
export function createMessageRouter(pi: ExtensionAPI, state: MessageRouterState): MessageRouter {
|
|
414
|
-
const
|
|
415
|
-
const inFlight = new Set<string>();
|
|
832
|
+
const routes = new Map<string, RouteState>();
|
|
416
833
|
let poller: ReturnType<typeof setInterval> | undefined;
|
|
834
|
+
let lastSweepAt = 0;
|
|
835
|
+
|
|
836
|
+
const notifyDropped = (msg: MessageRequest): void => {
|
|
837
|
+
(state.onMessageDropped ?? state.onMessageExpired)?.(msg);
|
|
838
|
+
};
|
|
839
|
+
|
|
840
|
+
/** Record a failed delivery attempt; drop the message after too many tries. */
|
|
841
|
+
const failDelivery = (msg: MessageRequest, entry: RouteState, now: number): void => {
|
|
842
|
+
entry.attempts += 1;
|
|
843
|
+
if (entry.attempts >= MAX_DELIVERY_ATTEMPTS) {
|
|
844
|
+
entry.expired = true;
|
|
845
|
+
routes.delete(msg.id);
|
|
846
|
+
notifyDropped(msg);
|
|
847
|
+
if (msg.expectsReply) {
|
|
848
|
+
try {
|
|
849
|
+
const senderDir = channelDir(state.root, msg.fromRunId, msg.fromAgent, msg.fromChildIndex);
|
|
850
|
+
writeReply(senderDir, msg.id, `[delivery error] Message could not be delivered after ${entry.attempts} attempts.`);
|
|
851
|
+
} catch {
|
|
852
|
+
/* best effort; the sender will eventually time out */
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
removeRequestFile(msg);
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
const backoff = Math.min(RETRY_BACKOFF_MAX_MS, RETRY_BACKOFF_BASE_MS * 2 ** (entry.attempts - 1));
|
|
859
|
+
entry.nextAttemptAt = now + backoff;
|
|
860
|
+
};
|
|
861
|
+
|
|
862
|
+
const expireMessage = (msg: MessageRequest, st: RouteState | undefined): void => {
|
|
863
|
+
if (!st) {
|
|
864
|
+
state.onMessageExpired?.(msg);
|
|
865
|
+
removeRequestFile(msg);
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
// Notify at most once, even if a delivery is still in flight; the
|
|
869
|
+
// running delivery keeps owning the entry until it settles.
|
|
870
|
+
if (!st.expired) {
|
|
871
|
+
st.expired = true;
|
|
872
|
+
state.onMessageExpired?.(msg);
|
|
873
|
+
removeRequestFile(msg);
|
|
874
|
+
}
|
|
875
|
+
if (!st.inFlight) routes.delete(msg.id);
|
|
876
|
+
};
|
|
417
877
|
|
|
418
878
|
const poll = () => {
|
|
879
|
+
const now = Date.now();
|
|
880
|
+
if (now - lastSweepAt >= SWEEP_INTERVAL_MS) {
|
|
881
|
+
lastSweepAt = now;
|
|
882
|
+
sweepRouteStates(routes, now);
|
|
883
|
+
try {
|
|
884
|
+
sweepChannelTree(state.root, now);
|
|
885
|
+
} catch {
|
|
886
|
+
/* janitor failures must never break routing */
|
|
887
|
+
}
|
|
888
|
+
}
|
|
419
889
|
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);
|
|
890
|
+
if (msg.expiresAt !== undefined && msg.expiresAt < now) {
|
|
891
|
+
expireMessage(msg, routes.get(msg.id));
|
|
425
892
|
continue;
|
|
426
893
|
}
|
|
427
|
-
|
|
894
|
+
const st = routes.get(msg.id);
|
|
895
|
+
if (st?.inFlight) continue;
|
|
896
|
+
if (st?.deliveredAt !== undefined) continue;
|
|
897
|
+
if (st && now < st.nextAttemptAt) continue;
|
|
428
898
|
if (!state.matchesContext(msg)) continue;
|
|
899
|
+
const entry: RouteState = st ?? { attempts: 0, nextAttemptAt: 0, inFlight: false };
|
|
900
|
+
entry.requestFile = msg.requestFile;
|
|
901
|
+
routes.set(msg.id, entry);
|
|
429
902
|
if (msg.to === MAIN_AGENT) {
|
|
430
903
|
try {
|
|
431
904
|
state.onMainMessage(msg);
|
|
432
905
|
// A request to main remains on disk until reply_message is
|
|
433
906
|
// called, so remember successful delivery to avoid injecting
|
|
434
|
-
// it on every poll. Failed delivery is
|
|
435
|
-
|
|
907
|
+
// it on every poll. Failed delivery is never marked seen; it
|
|
908
|
+
// retries with backoff and is dropped after too many tries.
|
|
909
|
+
entry.deliveredAt = now;
|
|
436
910
|
} catch {
|
|
437
|
-
|
|
911
|
+
failDelivery(msg, entry, now);
|
|
438
912
|
}
|
|
439
913
|
} else {
|
|
440
|
-
inFlight
|
|
914
|
+
entry.inFlight = true;
|
|
441
915
|
void Promise.resolve()
|
|
442
916
|
.then(() => state.onChildMessage(msg))
|
|
443
917
|
.then(() => {
|
|
444
|
-
|
|
918
|
+
// Do not mark delivered when the message expired while the
|
|
919
|
+
// delivery was running: its request file is already gone.
|
|
920
|
+
if (!entry.expired) entry.deliveredAt = Date.now();
|
|
445
921
|
})
|
|
446
922
|
.catch(() => {
|
|
447
|
-
|
|
923
|
+
// Delivery failed: never mark seen. Retry with backoff, or
|
|
924
|
+
// drop (and release waiters) once attempts are exhausted.
|
|
925
|
+
if (!entry.expired) failDelivery(msg, entry, Date.now());
|
|
448
926
|
})
|
|
449
927
|
.finally(() => {
|
|
450
|
-
inFlight
|
|
928
|
+
entry.inFlight = false;
|
|
929
|
+
// Delivered entries stay for TTL/bounded dedup and retrying
|
|
930
|
+
// entries keep their attempts/backoff state (deleting them
|
|
931
|
+
// would reset the backoff into a tight retry storm). Only
|
|
932
|
+
// expiry leaves no residue: its request file is already gone.
|
|
933
|
+
if (entry.expired && entry.deliveredAt === undefined) routes.delete(msg.id);
|
|
451
934
|
});
|
|
452
935
|
}
|
|
453
936
|
}
|
|
@@ -475,8 +958,8 @@ function registerMainReplyTool(pi: ExtensionAPI, state: MessageRouterState): voi
|
|
|
475
958
|
name: TOOL_SEND,
|
|
476
959
|
label: "Send Message",
|
|
477
960
|
description: [
|
|
478
|
-
"Send a message to a subagent: to=<
|
|
479
|
-
"The subagent processes the message and replies via reply_message.",
|
|
961
|
+
"Send a message to a subagent: to=<logical name> or an exact runtime address such as actor#01ab23cd. wait=true waits for its reply.",
|
|
962
|
+
"The subagent processes the message and replies via reply_message. During a synchronous task, use wait=false when contacting main.",
|
|
480
963
|
].join(" "),
|
|
481
964
|
parameters: Type.Object({
|
|
482
965
|
to: Type.String({ description: "Subagent name" }),
|
|
@@ -500,22 +983,20 @@ function registerMainReplyTool(pi: ExtensionAPI, state: MessageRouterState): voi
|
|
|
500
983
|
) * 1000;
|
|
501
984
|
const msg = findOrCreateProxyRequest(state.root, params.content, wait, timeoutMs);
|
|
502
985
|
if (!msg) {
|
|
503
|
-
return
|
|
986
|
+
return textResult("Message channel is not ready.");
|
|
504
987
|
}
|
|
505
988
|
const routed = { ...msg, to: params.to };
|
|
506
989
|
if (!wait) {
|
|
507
990
|
void Promise.resolve(state.onChildMessage(routed)).catch(() => {
|
|
508
991
|
/* fire-and-forget failures cannot be returned to this completed tool call */
|
|
509
992
|
});
|
|
510
|
-
return
|
|
993
|
+
return textResult(`Sent message to ${params.to}`);
|
|
511
994
|
}
|
|
512
995
|
try {
|
|
513
996
|
const reply = await state.onChildMessage(routed, signal);
|
|
514
|
-
return
|
|
997
|
+
return textResult(`Reply from ${params.to}:\n${reply}`);
|
|
515
998
|
} catch (e) {
|
|
516
|
-
return {
|
|
517
|
-
content: [{ type: "text", text: `Failed to message ${params.to}: ${e instanceof Error ? e.message : String(e)}` }],
|
|
518
|
-
};
|
|
999
|
+
return textResult(`Failed to message ${params.to}: ${e instanceof Error ? e.message : String(e)}`);
|
|
519
1000
|
}
|
|
520
1001
|
},
|
|
521
1002
|
});
|
|
@@ -534,15 +1015,13 @@ function registerMainReplyTool(pi: ExtensionAPI, state: MessageRouterState): voi
|
|
|
534
1015
|
execute: async (_id, params) => {
|
|
535
1016
|
const msg = findRequestInTree(state.root, params.message_id);
|
|
536
1017
|
if (!msg) {
|
|
537
|
-
return {
|
|
538
|
-
content: [{ type: "text", text: `Message not found: ${params.message_id.slice(0, 8)} (may already be handled)` }],
|
|
539
|
-
};
|
|
1018
|
+
return textResult(`Message not found: ${params.message_id.slice(0, 8)} (may already be handled)`);
|
|
540
1019
|
}
|
|
541
1020
|
const dir = channelDir(state.root, msg.fromRunId, msg.fromAgent, msg.fromChildIndex);
|
|
542
|
-
writeReply(dir, msg.id, params.content);
|
|
1021
|
+
writeReply(dir, msg.id, params.content, msg.to);
|
|
543
1022
|
removeRequestFile(msg);
|
|
544
1023
|
state.onMessageReplied?.(msg);
|
|
545
|
-
return
|
|
1024
|
+
return textResult(`Replied to ${msg.from}`);
|
|
546
1025
|
},
|
|
547
1026
|
});
|
|
548
1027
|
}
|
|
@@ -555,14 +1034,16 @@ function findOrCreateProxyRequest(
|
|
|
555
1034
|
timeoutMs: number,
|
|
556
1035
|
): MessageRequest | undefined {
|
|
557
1036
|
if (!root) return undefined;
|
|
1037
|
+
const now = Date.now();
|
|
558
1038
|
return {
|
|
559
1039
|
type: "pi.message.request",
|
|
560
1040
|
id: randomUUID(),
|
|
561
|
-
createdAt:
|
|
1041
|
+
createdAt: now,
|
|
562
1042
|
from: MAIN_AGENT,
|
|
563
1043
|
to: "",
|
|
564
1044
|
content,
|
|
565
1045
|
expectsReply,
|
|
1046
|
+
...(expectsReply ? { expiresAt: now + timeoutMs } : {}),
|
|
566
1047
|
timeoutMs,
|
|
567
1048
|
requestFile: "",
|
|
568
1049
|
fromRunId: "main",
|