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/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
- // send_message -> write <root>/<runId>/<agent>/<idx>/requests/<msgId>.json (to=target)
14
- // reply_message -> write <root>/<runId>/<fromAgent>/<idx>/replies/<msgId>.json
15
- // the waiting party polls replies/<msgId>.json
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 name
51
- to: string; // target: main or a subagent name
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
- return value.replace(/[^\w.-]+/g, "_");
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
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
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
- fs.writeFileSync(filePath, data, { encoding: "utf-8", mode: 0o600 });
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
- /* ignore cleanup failure */
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
- /* ignore */
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 = { type: "pi.message.reply", messageId, content, timestamp: Date.now() };
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
- try {
219
- if (!fs.existsSync(root)) return undefined;
220
- for (const run of fs.readdirSync(root)) {
221
- const runDir = path.join(root, run);
222
- if (!fs.statSync(runDir).isDirectory()) continue;
223
- for (const agent of fs.readdirSync(runDir)) {
224
- const agentDir = path.join(runDir, agent);
225
- if (!fs.statSync(agentDir).isDirectory()) continue;
226
- for (const idx of fs.readdirSync(agentDir)) {
227
- const reqDir = path.join(agentDir, idx, REQUESTS_DIR);
228
- if (!fs.existsSync(reqDir)) continue;
229
- for (const f of fs.readdirSync(reqDir)) {
230
- if (f !== `${safeSegment(messageId)}.json`) continue;
231
- try {
232
- return JSON.parse(fs.readFileSync(path.join(reqDir, f), "utf-8")) as MessageRequest;
233
- } catch {
234
- /* ignore */
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, to=<agent name> reaches that subagent.",
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 { content: [{ type: "text", text: `Sent message to ${params.to} (id ${messageId.slice(0, 8)})` }] };
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 { content: [{ type: "text", text: "Message channel is not available." }] };
306
- const dir = channelDir(meta.root, meta.runId, meta.agent, meta.childIndex);
307
- const reqDir = path.join(dir, REQUESTS_DIR);
308
- if (!fs.existsSync(reqDir)) return { content: [{ type: "text", text: "Inbox is empty." }] };
309
- const files = fs.readdirSync(reqDir).filter((f) => f.endsWith(".json"));
310
- if (files.length === 0) return { content: [{ type: "text", text: "Inbox is empty." }] };
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 { content: [{ type: "text", text: "Message channel is not available." }] };
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 { content: [{ type: "text", text: `Message not found: ${params.message_id.slice(0, 8)}` }] };
668
+ return textResult(`Message not found: ${params.message_id.slice(0, 8)}`);
336
669
  }
337
- return { content: [{ type: "text", text: `Replied to ${params.message_id.slice(0, 8)}` }] };
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
- try {
367
- if (!fs.existsSync(root)) return out;
368
- for (const run of fs.readdirSync(root)) {
369
- const runDir = path.join(root, run);
370
- if (!fs.statSync(runDir).isDirectory()) continue;
371
- for (const agent of fs.readdirSync(runDir)) {
372
- const agentDir = path.join(runDir, agent);
373
- if (!fs.statSync(agentDir).isDirectory()) continue;
374
- for (const idx of fs.readdirSync(agentDir)) {
375
- const reqDir = path.join(agentDir, idx, REQUESTS_DIR);
376
- if (!fs.existsSync(reqDir)) continue;
377
- for (const f of fs.readdirSync(reqDir)) {
378
- if (!f.endsWith(".json")) continue;
379
- try {
380
- const req = JSON.parse(fs.readFileSync(path.join(reqDir, f), "utf-8")) as MessageRequest;
381
- if (req.type === "pi.message.request" && req.id) {
382
- req.requestFile = path.join(reqDir, f);
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 seen = new Set<string>();
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 < Date.now()) {
421
- seen.delete(msg.id);
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
- if (seen.has(msg.id) || inFlight.has(msg.id)) continue;
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 deliberately retried.
435
- seen.add(msg.id);
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
- /* retry on the next poll */
923
+ failDelivery(msg, entry, now);
438
924
  }
439
925
  } else {
440
- inFlight.add(msg.id);
926
+ entry.inFlight = true;
441
927
  void Promise.resolve()
442
928
  .then(() => state.onChildMessage(msg))
443
929
  .then(() => {
444
- seen.add(msg.id);
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
- /* retry unexpected routing failures on the next poll */
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.delete(msg.id);
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=<agent name>. wait=true waits for its reply.",
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 { content: [{ type: "text", text: "Message channel is not ready." }] };
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 { content: [{ type: "text", text: `Sent message to ${params.to}` }] };
1005
+ return textResult(`Sent message to ${params.to}`);
511
1006
  }
512
1007
  try {
513
1008
  const reply = await state.onChildMessage(routed, signal);
514
- return { content: [{ type: "text", text: `Reply from ${params.to}:\n${reply}` }] };
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 { content: [{ type: "text", text: `Replied to ${msg.from}` }] };
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: Date.now(),
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",