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/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,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
- fs.writeFileSync(filePath, data, { encoding: "utf-8", mode: 0o600 });
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
- /* ignore cleanup failure */
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
- /* ignore */
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 = { type: "pi.message.reply", messageId, content, timestamp: Date.now() };
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
- 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 */
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, to=<agent name> reaches that subagent.",
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 { content: [{ type: "text", text: `Sent message to ${params.to} (id ${messageId.slice(0, 8)})` }] };
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 { 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")}` }] };
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 { content: [{ type: "text", text: "Message channel is not available." }] };
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 { content: [{ type: "text", text: `Message not found: ${params.message_id.slice(0, 8)}` }] };
656
+ return textResult(`Message not found: ${params.message_id.slice(0, 8)}`);
336
657
  }
337
- return { content: [{ type: "text", text: `Replied to ${params.message_id.slice(0, 8)}` }] };
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
- 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 */
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 seen = new Set<string>();
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 < Date.now()) {
421
- seen.delete(msg.id);
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
- if (seen.has(msg.id) || inFlight.has(msg.id)) continue;
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 deliberately retried.
435
- seen.add(msg.id);
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
- /* retry on the next poll */
911
+ failDelivery(msg, entry, now);
438
912
  }
439
913
  } else {
440
- inFlight.add(msg.id);
914
+ entry.inFlight = true;
441
915
  void Promise.resolve()
442
916
  .then(() => state.onChildMessage(msg))
443
917
  .then(() => {
444
- seen.add(msg.id);
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
- /* retry unexpected routing failures on the next poll */
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.delete(msg.id);
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=<agent name>. wait=true waits for its reply.",
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 { content: [{ type: "text", text: "Message channel is not ready." }] };
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 { content: [{ type: "text", text: `Sent message to ${params.to}` }] };
993
+ return textResult(`Sent message to ${params.to}`);
511
994
  }
512
995
  try {
513
996
  const reply = await state.onChildMessage(routed, signal);
514
- return { content: [{ type: "text", text: `Reply from ${params.to}:\n${reply}` }] };
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 { content: [{ type: "text", text: `Replied to ${msg.from}` }] };
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: Date.now(),
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",