taskplane 0.28.4 → 0.28.6

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.
Files changed (71) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +215 -215
  3. package/bin/gitignore-patterns.mjs +79 -79
  4. package/bin/rpc-wrapper.mjs +1086 -1086
  5. package/bin/taskplane.mjs +3254 -3254
  6. package/dashboard/public/app.js +2573 -2573
  7. package/dashboard/public/index.html +139 -139
  8. package/dashboard/public/style.css +1882 -1882
  9. package/dashboard/public/taskplane-word-color.svg +18 -18
  10. package/dashboard/public/taskplane-word-white.svg +18 -18
  11. package/dashboard/server.cjs +1666 -1666
  12. package/extensions/reviewer-extension.ts +119 -119
  13. package/extensions/task-orchestrator.ts +28 -28
  14. package/extensions/taskplane/abort.ts +502 -502
  15. package/extensions/taskplane/agent-bridge-extension.ts +838 -765
  16. package/extensions/taskplane/agent-host.ts +833 -745
  17. package/extensions/taskplane/cleanup.ts +747 -747
  18. package/extensions/taskplane/config-loader.ts +1328 -1322
  19. package/extensions/taskplane/config-schema.ts +692 -682
  20. package/extensions/taskplane/config.ts +73 -73
  21. package/extensions/taskplane/context-window.ts +66 -66
  22. package/extensions/taskplane/diagnostic-reports.ts +463 -463
  23. package/extensions/taskplane/diagnostics.ts +385 -385
  24. package/extensions/taskplane/engine-worker-entry.mjs +34 -34
  25. package/extensions/taskplane/engine-worker.ts +381 -381
  26. package/extensions/taskplane/engine.ts +4539 -4527
  27. package/extensions/taskplane/execution.ts +2733 -2708
  28. package/extensions/taskplane/extension.ts +30 -9
  29. package/extensions/taskplane/formatting.ts +773 -773
  30. package/extensions/taskplane/git.ts +90 -90
  31. package/extensions/taskplane/index.ts +28 -28
  32. package/extensions/taskplane/lane-runner.ts +1383 -1360
  33. package/extensions/taskplane/mailbox.ts +689 -689
  34. package/extensions/taskplane/merge.ts +3135 -3135
  35. package/extensions/taskplane/messages.ts +985 -985
  36. package/extensions/taskplane/migrations.ts +278 -278
  37. package/extensions/taskplane/naming.ts +117 -117
  38. package/extensions/taskplane/path-resolver.ts +237 -237
  39. package/extensions/taskplane/persistence.ts +2087 -2087
  40. package/extensions/taskplane/process-registry.ts +416 -416
  41. package/extensions/taskplane/quality-gate.ts +1033 -1033
  42. package/extensions/taskplane/resume.ts +2879 -2878
  43. package/extensions/taskplane/sessions.ts +57 -57
  44. package/extensions/taskplane/settings-loader.ts +136 -136
  45. package/extensions/taskplane/settings-tui.ts +1867 -1867
  46. package/extensions/taskplane/sidecar-telemetry.ts +252 -252
  47. package/extensions/taskplane/supervisor-primer.md +1694 -1694
  48. package/extensions/taskplane/supervisor.ts +4341 -4341
  49. package/extensions/taskplane/task-executor-core.ts +550 -550
  50. package/extensions/taskplane/tmux-compat.ts +37 -37
  51. package/extensions/taskplane/types.ts +4297 -4278
  52. package/extensions/taskplane/verification.ts +542 -542
  53. package/extensions/taskplane/waves.ts +1548 -1548
  54. package/extensions/taskplane/workspace.ts +705 -705
  55. package/extensions/taskplane/worktree.ts +2604 -2505
  56. package/package.json +57 -57
  57. package/skills/create-taskplane-task/SKILL.md +465 -465
  58. package/skills/create-taskplane-task/references/prompt-template.md +285 -285
  59. package/templates/agents/local/supervisor.md +33 -33
  60. package/templates/agents/local/task-merger.md +27 -27
  61. package/templates/agents/local/task-reviewer.md +30 -30
  62. package/templates/agents/local/task-worker.md +34 -34
  63. package/templates/agents/supervisor-routing.md +92 -92
  64. package/templates/agents/supervisor.md +168 -168
  65. package/templates/agents/task-merger.md +214 -214
  66. package/templates/agents/task-reviewer.md +192 -192
  67. package/templates/agents/task-worker.md +505 -429
  68. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -98
  69. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -73
  70. package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -97
  71. package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -73
@@ -1,689 +1,689 @@
1
- /**
2
- * Agent Mailbox — file-based cross-agent messaging utilities.
3
- *
4
- * Provides the core mailbox operations for the agent-mailbox-steering
5
- * protocol: write, read, and acknowledge messages in batch-scoped,
6
- * session-scoped inbox directories.
7
- *
8
- * Directory structure:
9
- * ```
10
- * .pi/mailbox/{batchId}/
11
- * ├── {sessionName}/
12
- * │ ├── inbox/ ← pending messages
13
- * │ └── ack/ ← processed messages (moved from inbox)
14
- * └── _broadcast/
15
- * └── inbox/ ← messages to all agents
16
- * ```
17
- *
18
- * All file operations are synchronous (matching rpc-wrapper pattern).
19
- * Write operations are atomic (temp file + rename in same directory).
20
- * Read/ack operations are best-effort (log warnings, don't crash).
21
- *
22
- * @module orch/mailbox
23
- * @since TP-089
24
- */
25
-
26
- import { join, dirname } from "path";
27
- import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync, appendFileSync } from "fs";
28
- import { randomBytes } from "crypto";
29
- import type { MailboxMessage, MailboxMessageType, WriteMailboxMessageOpts } from "./types.ts";
30
- import { MAILBOX_DIR_NAME, MAILBOX_MAX_CONTENT_BYTES, MAILBOX_MESSAGE_TYPES } from "./types.ts";
31
-
32
- // ── Path Helpers ─────────────────────────────────────────────────────
33
-
34
- /**
35
- * Root directory for all mailboxes in a batch.
36
- *
37
- * @param stateRoot - Root directory containing .pi/ (workspace root or repo root)
38
- * @param batchId - Batch ID for scoping
39
- * @returns Absolute path: `{stateRoot}/.pi/mailbox/{batchId}/`
40
- *
41
- * @since TP-089
42
- */
43
- export function mailboxRoot(stateRoot: string, batchId: string): string {
44
- return join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId);
45
- }
46
-
47
- /**
48
- * Inbox directory for a specific agent session.
49
- *
50
- * @param stateRoot - Root directory containing .pi/
51
- * @param batchId - Batch ID
52
- * @param sessionName - tmux session name (unique per batch)
53
- * @returns Absolute path: `{stateRoot}/.pi/mailbox/{batchId}/{sessionName}/inbox/`
54
- *
55
- * @since TP-089
56
- */
57
- export function sessionInboxDir(stateRoot: string, batchId: string, sessionName: string): string {
58
- return join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId, sessionName, "inbox");
59
- }
60
-
61
- /**
62
- * Ack directory for a specific agent session.
63
- *
64
- * @param stateRoot - Root directory containing .pi/
65
- * @param batchId - Batch ID
66
- * @param sessionName - tmux session name
67
- * @returns Absolute path: `{stateRoot}/.pi/mailbox/{batchId}/{sessionName}/ack/`
68
- *
69
- * @since TP-089
70
- */
71
- export function sessionAckDir(stateRoot: string, batchId: string, sessionName: string): string {
72
- return join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId, sessionName, "ack");
73
- }
74
-
75
- /**
76
- * Broadcast inbox directory (messages to all agents).
77
- *
78
- * @param stateRoot - Root directory containing .pi/
79
- * @param batchId - Batch ID
80
- * @returns Absolute path: `{stateRoot}/.pi/mailbox/{batchId}/_broadcast/inbox/`
81
- *
82
- * @since TP-089
83
- */
84
- export function broadcastInboxDir(stateRoot: string, batchId: string): string {
85
- return join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId, "_broadcast", "inbox");
86
- }
87
-
88
-
89
- // ── Write ────────────────────────────────────────────────────────────
90
-
91
- /**
92
- * Write a message to a target agent's inbox.
93
- *
94
- * Generates a unique message ID and writes the message atomically
95
- * (temp file + rename in the same directory). The temp file uses a
96
- * `.msg.json.tmp` extension that is excluded by the inbox reader's
97
- * `*.msg.json` filter.
98
- *
99
- * @param stateRoot - Root directory containing .pi/
100
- * @param batchId - Current batch ID
101
- * @param to - Target session name or `"_broadcast"`
102
- * @param opts - Message content and metadata from the caller
103
- * @returns The written MailboxMessage (including generated fields)
104
- * @throws If content exceeds 4KB UTF-8 bytes or file I/O fails
105
- *
106
- * @since TP-089
107
- */
108
- export function writeMailboxMessage(
109
- stateRoot: string,
110
- batchId: string,
111
- to: string,
112
- opts: WriteMailboxMessageOpts,
113
- ): MailboxMessage {
114
- // Validate content size (UTF-8 bytes, not string length)
115
- const contentBytes = Buffer.byteLength(opts.content, "utf8");
116
- if (contentBytes > MAILBOX_MAX_CONTENT_BYTES) {
117
- throw new Error(
118
- `Mailbox message content exceeds ${MAILBOX_MAX_CONTENT_BYTES} byte limit ` +
119
- `(${contentBytes} bytes). Steering messages should be concise directives. ` +
120
- `Write larger context to a file and reference it by path.`,
121
- );
122
- }
123
-
124
- // Generate unique message ID
125
- const timestamp = Date.now();
126
- const nonce = randomBytes(3).toString("hex").slice(0, 5);
127
- const id = `${timestamp}-${nonce}`;
128
-
129
- // Build the full message
130
- const message: MailboxMessage = {
131
- id,
132
- batchId,
133
- from: opts.from,
134
- to,
135
- timestamp,
136
- type: opts.type,
137
- content: opts.content,
138
- expectsReply: opts.expectsReply ?? false,
139
- replyTo: opts.replyTo ?? null,
140
- };
141
-
142
- // Determine inbox directory
143
- const inboxDir = to === "_broadcast"
144
- ? broadcastInboxDir(stateRoot, batchId)
145
- : sessionInboxDir(stateRoot, batchId, to);
146
-
147
- // Ensure inbox directory exists
148
- mkdirSync(inboxDir, { recursive: true });
149
-
150
- // Atomic write: temp file (.msg.json.tmp) then rename to final (.msg.json)
151
- const finalFilename = `${id}.msg.json`;
152
- const tempFilename = `${id}.msg.json.tmp`;
153
- const tempPath = join(inboxDir, tempFilename);
154
- const finalPath = join(inboxDir, finalFilename);
155
-
156
- try {
157
- writeFileSync(tempPath, JSON.stringify(message, null, 2) + "\n", "utf-8");
158
- renameSync(tempPath, finalPath);
159
- } catch (err) {
160
- // Attempt cleanup of temp file on failure
161
- try {
162
- if (existsSync(tempPath)) unlinkSync(tempPath);
163
- } catch {
164
- // Best effort cleanup
165
- }
166
- throw new Error(
167
- `Failed to write mailbox message to ${finalPath}: ${err instanceof Error ? err.message : String(err)}`,
168
- );
169
- }
170
-
171
- return message;
172
- }
173
-
174
-
175
- // ── Read ─────────────────────────────────────────────────────────────
176
-
177
- /**
178
- * Read pending messages from an inbox directory.
179
- *
180
- * Returns messages sorted by timestamp (ascending), with filename
181
- * lexical order as tie-breaker. Only reads files matching the
182
- * `*.msg.json` pattern (excludes `.msg.json.tmp` temp files).
183
- *
184
- * Messages with invalid shape or mismatched batchId are logged as
185
- * warnings and left in the inbox (no throw/crash).
186
- *
187
- * @param inboxDir - Absolute path to the inbox directory
188
- * @param expectedBatchId - Expected batch ID for validation
189
- * @returns Sorted array of `{ filename, message }` entries
190
- *
191
- * @since TP-089
192
- */
193
- export function readInbox(
194
- inboxDir: string,
195
- expectedBatchId: string,
196
- ): Array<{ filename: string; message: MailboxMessage }> {
197
- // Return empty if directory doesn't exist
198
- if (!existsSync(inboxDir)) return [];
199
-
200
- let entries: string[];
201
- try {
202
- entries = readdirSync(inboxDir);
203
- } catch (err) {
204
- process.stderr.write(
205
- `[mailbox] WARNING: failed to read inbox ${inboxDir}: ${err instanceof Error ? err.message : String(err)}\n`,
206
- );
207
- return [];
208
- }
209
-
210
- // Filter: only *.msg.json files (excludes .msg.json.tmp, .tmp, etc.)
211
- const msgFiles = entries.filter(f => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp"));
212
-
213
- const results: Array<{ filename: string; message: MailboxMessage }> = [];
214
-
215
- for (const filename of msgFiles) {
216
- const filePath = join(inboxDir, filename);
217
- let raw: string;
218
- try {
219
- raw = readFileSync(filePath, "utf-8");
220
- } catch (err) {
221
- process.stderr.write(
222
- `[mailbox] WARNING: failed to read ${filePath}: ${err instanceof Error ? err.message : String(err)}\n`,
223
- );
224
- continue;
225
- }
226
-
227
- let parsed: unknown;
228
- try {
229
- parsed = JSON.parse(raw);
230
- } catch {
231
- process.stderr.write(
232
- `[mailbox] WARNING: malformed JSON in ${filename}, skipping\n`,
233
- );
234
- continue;
235
- }
236
-
237
- // Validate shape
238
- if (!isValidMailboxMessage(parsed)) {
239
- process.stderr.write(
240
- `[mailbox] WARNING: invalid message shape in ${filename}, skipping\n`,
241
- );
242
- continue;
243
- }
244
-
245
- const msg = parsed as MailboxMessage;
246
-
247
- // Validate batchId
248
- if (msg.batchId !== expectedBatchId) {
249
- process.stderr.write(
250
- `[mailbox] WARNING: batchId mismatch in ${filename} (expected ${expectedBatchId}, got ${msg.batchId}), skipping\n`,
251
- );
252
- continue;
253
- }
254
-
255
- results.push({ filename, message: msg });
256
- }
257
-
258
- // Sort: primary by timestamp (ascending), tie-break by filename lexical
259
- results.sort((a, b) => {
260
- const tsDiff = a.message.timestamp - b.message.timestamp;
261
- if (tsDiff !== 0) return tsDiff;
262
- return a.filename.localeCompare(b.filename);
263
- });
264
-
265
- return results;
266
- }
267
-
268
-
269
- // ── Acknowledge ──────────────────────────────────────────────────────
270
-
271
- /**
272
- * Move a message from inbox to ack directory.
273
- *
274
- * Atomic rename. If the file is already gone (another process acked it),
275
- * returns false. The ack directory is derived structurally from the inbox
276
- * directory: `dirname(inboxDir)/ack/`.
277
- *
278
- * @param inboxDir - Absolute path to the inbox directory
279
- * @param filename - Message filename (e.g., `1774744971303-a7f2c.msg.json`)
280
- * @returns true if acked successfully, false if already acked (ENOENT race)
281
- *
282
- * @since TP-089
283
- */
284
- export function ackMessage(inboxDir: string, filename: string): boolean {
285
- const ackDir = join(dirname(inboxDir), "ack");
286
-
287
- try {
288
- mkdirSync(ackDir, { recursive: true });
289
- } catch (err) {
290
- process.stderr.write(
291
- `[mailbox] WARNING: failed to create ack dir ${ackDir}: ${err instanceof Error ? err.message : String(err)}\n`,
292
- );
293
- return false;
294
- }
295
-
296
- const srcPath = join(inboxDir, filename);
297
- const dstPath = join(ackDir, filename);
298
-
299
- try {
300
- renameSync(srcPath, dstPath);
301
- return true;
302
- } catch (err: unknown) {
303
- const code = (err as NodeJS.ErrnoException).code;
304
- if (code === "ENOENT") {
305
- // Another process already acked this message — race is harmless
306
- return false;
307
- }
308
- process.stderr.write(
309
- `[mailbox] WARNING: failed to ack ${filename}: ${err instanceof Error ? err.message : String(err)}\n`,
310
- );
311
- return false;
312
- }
313
- }
314
-
315
-
316
- // ── Validation ───────────────────────────────────────────────────────
317
-
318
- /**
319
- * Runtime validation for mailbox message shape.
320
- *
321
- * Checks that all required fields are present and correctly typed.
322
- * Does not validate batchId match (caller's responsibility).
323
- *
324
- * @param obj - Parsed JSON value to validate
325
- * @returns true if obj is a valid MailboxMessage shape
326
- *
327
- * @since TP-089
328
- */
329
- export function isValidMailboxMessage(obj: unknown): obj is MailboxMessage {
330
- if (!obj || typeof obj !== "object") return false;
331
- const m = obj as Record<string, unknown>;
332
- return (
333
- typeof m.id === "string" &&
334
- typeof m.batchId === "string" &&
335
- typeof m.from === "string" &&
336
- typeof m.to === "string" &&
337
- typeof m.timestamp === "number" && Number.isFinite(m.timestamp) &&
338
- typeof m.type === "string" && MAILBOX_MESSAGE_TYPES.has(m.type) &&
339
- typeof m.content === "string"
340
- );
341
- }
342
-
343
-
344
- // ── Outbox (Agent → Supervisor, TP-106) ─────────────────────────
345
-
346
- /**
347
- * Outbox directory for a specific agent session.
348
- *
349
- * @param stateRoot - Root directory containing .pi/
350
- * @param batchId - Batch ID
351
- * @param sessionName - Agent ID / session name
352
- * @returns Absolute path: `{stateRoot}/.pi/mailbox/{batchId}/{sessionName}/outbox/`
353
- *
354
- * @since TP-106
355
- */
356
- export function sessionOutboxDir(stateRoot: string, batchId: string, sessionName: string): string {
357
- return join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId, sessionName, "outbox");
358
- }
359
-
360
- /**
361
- * Write a reply or escalation message to an agent's outbox.
362
- *
363
- * Used by agents (via bridge tools or direct write) to communicate
364
- * back to the supervisor. The engine or lane-runner polls outbox
365
- * directories and surfaces messages as supervisor alerts.
366
- *
367
- * @param stateRoot - Root directory containing .pi/
368
- * @param batchId - Current batch ID
369
- * @param from - Agent ID writing the message
370
- * @param opts - Message content and metadata
371
- * @returns The written MailboxMessage
372
- *
373
- * @since TP-106
374
- */
375
- export function writeOutboxMessage(
376
- stateRoot: string,
377
- batchId: string,
378
- from: string,
379
- opts: WriteMailboxMessageOpts,
380
- ): MailboxMessage {
381
- const outboxDir = sessionOutboxDir(stateRoot, batchId, from);
382
- mkdirSync(outboxDir, { recursive: true });
383
-
384
- const contentBytes = Buffer.byteLength(opts.content, "utf8");
385
- if (contentBytes > MAILBOX_MAX_CONTENT_BYTES) {
386
- throw new Error(
387
- `Outbox message content exceeds ${MAILBOX_MAX_CONTENT_BYTES} byte limit (${contentBytes} bytes).`,
388
- );
389
- }
390
-
391
- const timestamp = Date.now();
392
- const nonce = randomBytes(3).toString("hex").slice(0, 5);
393
- const id = `${timestamp}-${nonce}`;
394
-
395
- const message: MailboxMessage = {
396
- id,
397
- batchId,
398
- from,
399
- to: "supervisor",
400
- timestamp,
401
- type: opts.type,
402
- content: opts.content,
403
- expectsReply: opts.expectsReply ?? false,
404
- replyTo: opts.replyTo ?? null,
405
- };
406
-
407
- const finalFilename = `${id}.msg.json`;
408
- const tempFilename = `${id}.msg.json.tmp`;
409
- const tempPath = join(outboxDir, tempFilename);
410
- const finalPath = join(outboxDir, finalFilename);
411
-
412
- try {
413
- writeFileSync(tempPath, JSON.stringify(message, null, 2) + "\n", "utf-8");
414
- renameSync(tempPath, finalPath);
415
- } catch (err) {
416
- try { if (existsSync(tempPath)) unlinkSync(tempPath); } catch { /* cleanup */ }
417
- throw new Error(`Failed to write outbox message: ${err instanceof Error ? err.message : String(err)}`);
418
- }
419
-
420
- return message;
421
- }
422
-
423
- /**
424
- * Read pending outbox messages from an agent's outbox directory.
425
- *
426
- * @param stateRoot - Root directory containing .pi/
427
- * @param batchId - Batch ID
428
- * @param agentId - Agent ID whose outbox to read
429
- * @returns Array of outbox messages sorted by timestamp
430
- *
431
- * @since TP-106
432
- */
433
- export function readOutbox(
434
- stateRoot: string,
435
- batchId: string,
436
- agentId: string,
437
- ): MailboxMessage[] {
438
- const outboxDir = sessionOutboxDir(stateRoot, batchId, agentId);
439
- if (!existsSync(outboxDir)) return [];
440
-
441
- let entries: string[];
442
- try {
443
- entries = readdirSync(outboxDir);
444
- } catch {
445
- return [];
446
- }
447
-
448
- const msgFiles = entries.filter(f => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp"));
449
- const messages: MailboxMessage[] = [];
450
-
451
- for (const filename of msgFiles) {
452
- try {
453
- const raw = readFileSync(join(outboxDir, filename), "utf-8");
454
- const parsed = JSON.parse(raw);
455
- if (isValidMailboxMessage(parsed)) {
456
- messages.push(parsed);
457
- }
458
- } catch { /* skip malformed */ }
459
- }
460
-
461
- messages.sort((a, b) => a.timestamp - b.timestamp);
462
- return messages;
463
- }
464
-
465
- /**
466
- * Read all outbox messages (pending + processed) for durable history.
467
- *
468
- * Unlike readOutbox() which only reads pending messages, this function
469
- * also reads outbox/processed/ so consumed replies remain visible to
470
- * the supervisor via read_agent_replies.
471
- *
472
- * @param stateRoot - Root directory containing .pi/
473
- * @param batchId - Batch ID
474
- * @param agentId - Agent ID whose outbox history to read
475
- * @returns Array of { message, acked } sorted by timestamp
476
- *
477
- * @since TP-091
478
- */
479
- export function readOutboxHistory(
480
- stateRoot: string,
481
- batchId: string,
482
- agentId: string,
483
- ): Array<{ message: MailboxMessage; acked: boolean }> {
484
- const outboxDir = sessionOutboxDir(stateRoot, batchId, agentId);
485
- const results: Array<{ message: MailboxMessage; acked: boolean }> = [];
486
-
487
- for (const [dir, acked] of [[outboxDir, false], [join(outboxDir, "processed"), true]] as const) {
488
- if (!existsSync(dir)) continue;
489
- let entries: string[];
490
- try { entries = readdirSync(dir); } catch { continue; }
491
-
492
- const msgFiles = entries.filter(f => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp"));
493
- for (const filename of msgFiles) {
494
- try {
495
- const raw = readFileSync(join(dir, filename), "utf-8");
496
- const parsed = JSON.parse(raw);
497
- if (isValidMailboxMessage(parsed)) {
498
- results.push({ message: parsed, acked });
499
- }
500
- } catch { /* skip malformed */ }
501
- }
502
- }
503
-
504
- results.sort((a, b) => a.message.timestamp - b.message.timestamp);
505
- return results;
506
- }
507
-
508
- /**
509
- * Ack (consume) a specific outbox message by moving it to processed/.
510
- *
511
- * Returns false if the message is already gone (race-safe/idempotent).
512
- *
513
- * @since TP-106
514
- */
515
- export function ackOutboxMessage(
516
- stateRoot: string,
517
- batchId: string,
518
- agentId: string,
519
- messageId: string,
520
- ): boolean {
521
- const outboxDir = sessionOutboxDir(stateRoot, batchId, agentId);
522
- const processedDir = join(outboxDir, "processed");
523
- const file = `${messageId}.msg.json`;
524
- const srcPath = join(outboxDir, file);
525
- const dstPath = join(processedDir, file);
526
-
527
- try {
528
- mkdirSync(processedDir, { recursive: true });
529
- renameSync(srcPath, dstPath);
530
- return true;
531
- } catch (err: unknown) {
532
- const code = (err as NodeJS.ErrnoException).code;
533
- if (code === "ENOENT") return false;
534
- process.stderr.write(
535
- `[mailbox] WARNING: failed to ack outbox ${file}: ${err instanceof Error ? err.message : String(err)}\n`,
536
- );
537
- return false;
538
- }
539
- }
540
-
541
- /**
542
- * Discover all agent IDs that have mailbox directories for a batch.
543
- * Returns directory names under .pi/mailbox/{batchId}/ excluding _broadcast.
544
- * Used to find agents with historical messages even if no longer in the registry.
545
- *
546
- * @param stateRoot - Root directory containing .pi/
547
- * @param batchId - Batch ID
548
- * @returns Array of agent IDs found in mailbox directories
549
- *
550
- * @since TP-091
551
- */
552
- export function discoverMailboxAgentIds(
553
- stateRoot: string,
554
- batchId: string,
555
- ): string[] {
556
- const mbRoot = join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId);
557
- if (!existsSync(mbRoot)) return [];
558
- try {
559
- const entries = readdirSync(mbRoot, { withFileTypes: true });
560
- return entries
561
- .filter(e => e.isDirectory() && e.name !== "_broadcast")
562
- .map(e => e.name);
563
- } catch {
564
- return [];
565
- }
566
- }
567
-
568
-
569
- export type MailboxAuditEventType =
570
- | "message_sent"
571
- | "message_delivered"
572
- | "message_replied"
573
- | "message_escalated"
574
- | "message_rate_limited";
575
-
576
- /**
577
- * Append a mailbox audit event to .pi/mailbox/{batchId}/events.jsonl.
578
- *
579
- * Best-effort: logs warning but never throws.
580
- *
581
- * @since TP-106
582
- */
583
- export function appendMailboxAuditEvent(
584
- stateRoot: string,
585
- batchId: string,
586
- event: {
587
- type: MailboxAuditEventType;
588
- ts?: number;
589
- from?: string;
590
- to?: string;
591
- messageId?: string;
592
- messageType?: string;
593
- contentPreview?: string;
594
- broadcast?: boolean;
595
- reason?: string;
596
- retryAfterMs?: number;
597
- },
598
- ): void {
599
- const eventsPath = join(mailboxRoot(stateRoot, batchId), "events.jsonl");
600
- try {
601
- mkdirSync(dirname(eventsPath), { recursive: true });
602
- appendFileSync(
603
- eventsPath,
604
- JSON.stringify({ batchId, ts: event.ts ?? Date.now(), ...event }) + "\n",
605
- "utf-8",
606
- );
607
- } catch (err) {
608
- process.stderr.write(
609
- `[mailbox] WARNING: failed to append mailbox event: ${err instanceof Error ? err.message : String(err)}\n`,
610
- );
611
- }
612
- }
613
-
614
-
615
- // ── Broadcast (TP-106) ────────────────────────────────────────
616
-
617
- /**
618
- * Write a broadcast message to all agents.
619
- *
620
- * The message is written to `_broadcast/inbox/`. Agent hosts check
621
- * this directory alongside their own inbox on each `message_end`.
622
- *
623
- * @param stateRoot - Root directory containing .pi/
624
- * @param batchId - Current batch ID
625
- * @param opts - Message content and metadata
626
- * @returns The written MailboxMessage
627
- *
628
- * @since TP-106
629
- */
630
- export function writeBroadcastMessage(
631
- stateRoot: string,
632
- batchId: string,
633
- opts: WriteMailboxMessageOpts,
634
- ): MailboxMessage {
635
- return writeMailboxMessage(stateRoot, batchId, "_broadcast", {
636
- ...opts,
637
- from: opts.from || "supervisor",
638
- });
639
- }
640
-
641
-
642
- // ── Rate Limiting (TP-106) ─────────────────────────────────────
643
-
644
- /** Default rate limit: max 1 message per agent per 30 seconds. */
645
- export const RATE_LIMIT_WINDOW_MS = 30_000;
646
-
647
- /** In-memory rate limit tracker. Keyed by target agent ID. */
648
- const rateLimitTracker = new Map<string, number>();
649
-
650
- /**
651
- * Check whether sending a message to a target is rate-limited.
652
- *
653
- * @param targetAgentId - Agent ID being sent to
654
- * @param windowMs - Rate limit window in ms (default: 30_000)
655
- * @returns Object with `allowed` and optional `retryAfterMs`
656
- *
657
- * @since TP-106
658
- */
659
- export function checkRateLimit(
660
- targetAgentId: string,
661
- windowMs: number = RATE_LIMIT_WINDOW_MS,
662
- ): { allowed: boolean; retryAfterMs?: number } {
663
- const lastSent = rateLimitTracker.get(targetAgentId);
664
- if (!lastSent) return { allowed: true };
665
-
666
- const elapsed = Date.now() - lastSent;
667
- if (elapsed >= windowMs) return { allowed: true };
668
-
669
- return { allowed: false, retryAfterMs: windowMs - elapsed };
670
- }
671
-
672
- /**
673
- * Record a send timestamp for rate limiting.
674
- *
675
- * @param targetAgentId - Agent ID that was sent to
676
- *
677
- * @since TP-106
678
- */
679
- export function recordSend(targetAgentId: string): void {
680
- rateLimitTracker.set(targetAgentId, Date.now());
681
- }
682
-
683
- /**
684
- * Reset rate limit state (for testing).
685
- * @since TP-106
686
- */
687
- export function _resetRateLimits(): void {
688
- rateLimitTracker.clear();
689
- }
1
+ /**
2
+ * Agent Mailbox — file-based cross-agent messaging utilities.
3
+ *
4
+ * Provides the core mailbox operations for the agent-mailbox-steering
5
+ * protocol: write, read, and acknowledge messages in batch-scoped,
6
+ * session-scoped inbox directories.
7
+ *
8
+ * Directory structure:
9
+ * ```
10
+ * .pi/mailbox/{batchId}/
11
+ * ├── {sessionName}/
12
+ * │ ├── inbox/ ← pending messages
13
+ * │ └── ack/ ← processed messages (moved from inbox)
14
+ * └── _broadcast/
15
+ * └── inbox/ ← messages to all agents
16
+ * ```
17
+ *
18
+ * All file operations are synchronous (matching rpc-wrapper pattern).
19
+ * Write operations are atomic (temp file + rename in same directory).
20
+ * Read/ack operations are best-effort (log warnings, don't crash).
21
+ *
22
+ * @module orch/mailbox
23
+ * @since TP-089
24
+ */
25
+
26
+ import { join, dirname } from "path";
27
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync, appendFileSync } from "fs";
28
+ import { randomBytes } from "crypto";
29
+ import type { MailboxMessage, MailboxMessageType, WriteMailboxMessageOpts } from "./types.ts";
30
+ import { MAILBOX_DIR_NAME, MAILBOX_MAX_CONTENT_BYTES, MAILBOX_MESSAGE_TYPES } from "./types.ts";
31
+
32
+ // ── Path Helpers ─────────────────────────────────────────────────────
33
+
34
+ /**
35
+ * Root directory for all mailboxes in a batch.
36
+ *
37
+ * @param stateRoot - Root directory containing .pi/ (workspace root or repo root)
38
+ * @param batchId - Batch ID for scoping
39
+ * @returns Absolute path: `{stateRoot}/.pi/mailbox/{batchId}/`
40
+ *
41
+ * @since TP-089
42
+ */
43
+ export function mailboxRoot(stateRoot: string, batchId: string): string {
44
+ return join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId);
45
+ }
46
+
47
+ /**
48
+ * Inbox directory for a specific agent session.
49
+ *
50
+ * @param stateRoot - Root directory containing .pi/
51
+ * @param batchId - Batch ID
52
+ * @param sessionName - tmux session name (unique per batch)
53
+ * @returns Absolute path: `{stateRoot}/.pi/mailbox/{batchId}/{sessionName}/inbox/`
54
+ *
55
+ * @since TP-089
56
+ */
57
+ export function sessionInboxDir(stateRoot: string, batchId: string, sessionName: string): string {
58
+ return join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId, sessionName, "inbox");
59
+ }
60
+
61
+ /**
62
+ * Ack directory for a specific agent session.
63
+ *
64
+ * @param stateRoot - Root directory containing .pi/
65
+ * @param batchId - Batch ID
66
+ * @param sessionName - tmux session name
67
+ * @returns Absolute path: `{stateRoot}/.pi/mailbox/{batchId}/{sessionName}/ack/`
68
+ *
69
+ * @since TP-089
70
+ */
71
+ export function sessionAckDir(stateRoot: string, batchId: string, sessionName: string): string {
72
+ return join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId, sessionName, "ack");
73
+ }
74
+
75
+ /**
76
+ * Broadcast inbox directory (messages to all agents).
77
+ *
78
+ * @param stateRoot - Root directory containing .pi/
79
+ * @param batchId - Batch ID
80
+ * @returns Absolute path: `{stateRoot}/.pi/mailbox/{batchId}/_broadcast/inbox/`
81
+ *
82
+ * @since TP-089
83
+ */
84
+ export function broadcastInboxDir(stateRoot: string, batchId: string): string {
85
+ return join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId, "_broadcast", "inbox");
86
+ }
87
+
88
+
89
+ // ── Write ────────────────────────────────────────────────────────────
90
+
91
+ /**
92
+ * Write a message to a target agent's inbox.
93
+ *
94
+ * Generates a unique message ID and writes the message atomically
95
+ * (temp file + rename in the same directory). The temp file uses a
96
+ * `.msg.json.tmp` extension that is excluded by the inbox reader's
97
+ * `*.msg.json` filter.
98
+ *
99
+ * @param stateRoot - Root directory containing .pi/
100
+ * @param batchId - Current batch ID
101
+ * @param to - Target session name or `"_broadcast"`
102
+ * @param opts - Message content and metadata from the caller
103
+ * @returns The written MailboxMessage (including generated fields)
104
+ * @throws If content exceeds 4KB UTF-8 bytes or file I/O fails
105
+ *
106
+ * @since TP-089
107
+ */
108
+ export function writeMailboxMessage(
109
+ stateRoot: string,
110
+ batchId: string,
111
+ to: string,
112
+ opts: WriteMailboxMessageOpts,
113
+ ): MailboxMessage {
114
+ // Validate content size (UTF-8 bytes, not string length)
115
+ const contentBytes = Buffer.byteLength(opts.content, "utf8");
116
+ if (contentBytes > MAILBOX_MAX_CONTENT_BYTES) {
117
+ throw new Error(
118
+ `Mailbox message content exceeds ${MAILBOX_MAX_CONTENT_BYTES} byte limit ` +
119
+ `(${contentBytes} bytes). Steering messages should be concise directives. ` +
120
+ `Write larger context to a file and reference it by path.`,
121
+ );
122
+ }
123
+
124
+ // Generate unique message ID
125
+ const timestamp = Date.now();
126
+ const nonce = randomBytes(3).toString("hex").slice(0, 5);
127
+ const id = `${timestamp}-${nonce}`;
128
+
129
+ // Build the full message
130
+ const message: MailboxMessage = {
131
+ id,
132
+ batchId,
133
+ from: opts.from,
134
+ to,
135
+ timestamp,
136
+ type: opts.type,
137
+ content: opts.content,
138
+ expectsReply: opts.expectsReply ?? false,
139
+ replyTo: opts.replyTo ?? null,
140
+ };
141
+
142
+ // Determine inbox directory
143
+ const inboxDir = to === "_broadcast"
144
+ ? broadcastInboxDir(stateRoot, batchId)
145
+ : sessionInboxDir(stateRoot, batchId, to);
146
+
147
+ // Ensure inbox directory exists
148
+ mkdirSync(inboxDir, { recursive: true });
149
+
150
+ // Atomic write: temp file (.msg.json.tmp) then rename to final (.msg.json)
151
+ const finalFilename = `${id}.msg.json`;
152
+ const tempFilename = `${id}.msg.json.tmp`;
153
+ const tempPath = join(inboxDir, tempFilename);
154
+ const finalPath = join(inboxDir, finalFilename);
155
+
156
+ try {
157
+ writeFileSync(tempPath, JSON.stringify(message, null, 2) + "\n", "utf-8");
158
+ renameSync(tempPath, finalPath);
159
+ } catch (err) {
160
+ // Attempt cleanup of temp file on failure
161
+ try {
162
+ if (existsSync(tempPath)) unlinkSync(tempPath);
163
+ } catch {
164
+ // Best effort cleanup
165
+ }
166
+ throw new Error(
167
+ `Failed to write mailbox message to ${finalPath}: ${err instanceof Error ? err.message : String(err)}`,
168
+ );
169
+ }
170
+
171
+ return message;
172
+ }
173
+
174
+
175
+ // ── Read ─────────────────────────────────────────────────────────────
176
+
177
+ /**
178
+ * Read pending messages from an inbox directory.
179
+ *
180
+ * Returns messages sorted by timestamp (ascending), with filename
181
+ * lexical order as tie-breaker. Only reads files matching the
182
+ * `*.msg.json` pattern (excludes `.msg.json.tmp` temp files).
183
+ *
184
+ * Messages with invalid shape or mismatched batchId are logged as
185
+ * warnings and left in the inbox (no throw/crash).
186
+ *
187
+ * @param inboxDir - Absolute path to the inbox directory
188
+ * @param expectedBatchId - Expected batch ID for validation
189
+ * @returns Sorted array of `{ filename, message }` entries
190
+ *
191
+ * @since TP-089
192
+ */
193
+ export function readInbox(
194
+ inboxDir: string,
195
+ expectedBatchId: string,
196
+ ): Array<{ filename: string; message: MailboxMessage }> {
197
+ // Return empty if directory doesn't exist
198
+ if (!existsSync(inboxDir)) return [];
199
+
200
+ let entries: string[];
201
+ try {
202
+ entries = readdirSync(inboxDir);
203
+ } catch (err) {
204
+ process.stderr.write(
205
+ `[mailbox] WARNING: failed to read inbox ${inboxDir}: ${err instanceof Error ? err.message : String(err)}\n`,
206
+ );
207
+ return [];
208
+ }
209
+
210
+ // Filter: only *.msg.json files (excludes .msg.json.tmp, .tmp, etc.)
211
+ const msgFiles = entries.filter(f => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp"));
212
+
213
+ const results: Array<{ filename: string; message: MailboxMessage }> = [];
214
+
215
+ for (const filename of msgFiles) {
216
+ const filePath = join(inboxDir, filename);
217
+ let raw: string;
218
+ try {
219
+ raw = readFileSync(filePath, "utf-8");
220
+ } catch (err) {
221
+ process.stderr.write(
222
+ `[mailbox] WARNING: failed to read ${filePath}: ${err instanceof Error ? err.message : String(err)}\n`,
223
+ );
224
+ continue;
225
+ }
226
+
227
+ let parsed: unknown;
228
+ try {
229
+ parsed = JSON.parse(raw);
230
+ } catch {
231
+ process.stderr.write(
232
+ `[mailbox] WARNING: malformed JSON in ${filename}, skipping\n`,
233
+ );
234
+ continue;
235
+ }
236
+
237
+ // Validate shape
238
+ if (!isValidMailboxMessage(parsed)) {
239
+ process.stderr.write(
240
+ `[mailbox] WARNING: invalid message shape in ${filename}, skipping\n`,
241
+ );
242
+ continue;
243
+ }
244
+
245
+ const msg = parsed as MailboxMessage;
246
+
247
+ // Validate batchId
248
+ if (msg.batchId !== expectedBatchId) {
249
+ process.stderr.write(
250
+ `[mailbox] WARNING: batchId mismatch in ${filename} (expected ${expectedBatchId}, got ${msg.batchId}), skipping\n`,
251
+ );
252
+ continue;
253
+ }
254
+
255
+ results.push({ filename, message: msg });
256
+ }
257
+
258
+ // Sort: primary by timestamp (ascending), tie-break by filename lexical
259
+ results.sort((a, b) => {
260
+ const tsDiff = a.message.timestamp - b.message.timestamp;
261
+ if (tsDiff !== 0) return tsDiff;
262
+ return a.filename.localeCompare(b.filename);
263
+ });
264
+
265
+ return results;
266
+ }
267
+
268
+
269
+ // ── Acknowledge ──────────────────────────────────────────────────────
270
+
271
+ /**
272
+ * Move a message from inbox to ack directory.
273
+ *
274
+ * Atomic rename. If the file is already gone (another process acked it),
275
+ * returns false. The ack directory is derived structurally from the inbox
276
+ * directory: `dirname(inboxDir)/ack/`.
277
+ *
278
+ * @param inboxDir - Absolute path to the inbox directory
279
+ * @param filename - Message filename (e.g., `1774744971303-a7f2c.msg.json`)
280
+ * @returns true if acked successfully, false if already acked (ENOENT race)
281
+ *
282
+ * @since TP-089
283
+ */
284
+ export function ackMessage(inboxDir: string, filename: string): boolean {
285
+ const ackDir = join(dirname(inboxDir), "ack");
286
+
287
+ try {
288
+ mkdirSync(ackDir, { recursive: true });
289
+ } catch (err) {
290
+ process.stderr.write(
291
+ `[mailbox] WARNING: failed to create ack dir ${ackDir}: ${err instanceof Error ? err.message : String(err)}\n`,
292
+ );
293
+ return false;
294
+ }
295
+
296
+ const srcPath = join(inboxDir, filename);
297
+ const dstPath = join(ackDir, filename);
298
+
299
+ try {
300
+ renameSync(srcPath, dstPath);
301
+ return true;
302
+ } catch (err: unknown) {
303
+ const code = (err as NodeJS.ErrnoException).code;
304
+ if (code === "ENOENT") {
305
+ // Another process already acked this message — race is harmless
306
+ return false;
307
+ }
308
+ process.stderr.write(
309
+ `[mailbox] WARNING: failed to ack ${filename}: ${err instanceof Error ? err.message : String(err)}\n`,
310
+ );
311
+ return false;
312
+ }
313
+ }
314
+
315
+
316
+ // ── Validation ───────────────────────────────────────────────────────
317
+
318
+ /**
319
+ * Runtime validation for mailbox message shape.
320
+ *
321
+ * Checks that all required fields are present and correctly typed.
322
+ * Does not validate batchId match (caller's responsibility).
323
+ *
324
+ * @param obj - Parsed JSON value to validate
325
+ * @returns true if obj is a valid MailboxMessage shape
326
+ *
327
+ * @since TP-089
328
+ */
329
+ export function isValidMailboxMessage(obj: unknown): obj is MailboxMessage {
330
+ if (!obj || typeof obj !== "object") return false;
331
+ const m = obj as Record<string, unknown>;
332
+ return (
333
+ typeof m.id === "string" &&
334
+ typeof m.batchId === "string" &&
335
+ typeof m.from === "string" &&
336
+ typeof m.to === "string" &&
337
+ typeof m.timestamp === "number" && Number.isFinite(m.timestamp) &&
338
+ typeof m.type === "string" && MAILBOX_MESSAGE_TYPES.has(m.type) &&
339
+ typeof m.content === "string"
340
+ );
341
+ }
342
+
343
+
344
+ // ── Outbox (Agent → Supervisor, TP-106) ─────────────────────────
345
+
346
+ /**
347
+ * Outbox directory for a specific agent session.
348
+ *
349
+ * @param stateRoot - Root directory containing .pi/
350
+ * @param batchId - Batch ID
351
+ * @param sessionName - Agent ID / session name
352
+ * @returns Absolute path: `{stateRoot}/.pi/mailbox/{batchId}/{sessionName}/outbox/`
353
+ *
354
+ * @since TP-106
355
+ */
356
+ export function sessionOutboxDir(stateRoot: string, batchId: string, sessionName: string): string {
357
+ return join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId, sessionName, "outbox");
358
+ }
359
+
360
+ /**
361
+ * Write a reply or escalation message to an agent's outbox.
362
+ *
363
+ * Used by agents (via bridge tools or direct write) to communicate
364
+ * back to the supervisor. The engine or lane-runner polls outbox
365
+ * directories and surfaces messages as supervisor alerts.
366
+ *
367
+ * @param stateRoot - Root directory containing .pi/
368
+ * @param batchId - Current batch ID
369
+ * @param from - Agent ID writing the message
370
+ * @param opts - Message content and metadata
371
+ * @returns The written MailboxMessage
372
+ *
373
+ * @since TP-106
374
+ */
375
+ export function writeOutboxMessage(
376
+ stateRoot: string,
377
+ batchId: string,
378
+ from: string,
379
+ opts: WriteMailboxMessageOpts,
380
+ ): MailboxMessage {
381
+ const outboxDir = sessionOutboxDir(stateRoot, batchId, from);
382
+ mkdirSync(outboxDir, { recursive: true });
383
+
384
+ const contentBytes = Buffer.byteLength(opts.content, "utf8");
385
+ if (contentBytes > MAILBOX_MAX_CONTENT_BYTES) {
386
+ throw new Error(
387
+ `Outbox message content exceeds ${MAILBOX_MAX_CONTENT_BYTES} byte limit (${contentBytes} bytes).`,
388
+ );
389
+ }
390
+
391
+ const timestamp = Date.now();
392
+ const nonce = randomBytes(3).toString("hex").slice(0, 5);
393
+ const id = `${timestamp}-${nonce}`;
394
+
395
+ const message: MailboxMessage = {
396
+ id,
397
+ batchId,
398
+ from,
399
+ to: "supervisor",
400
+ timestamp,
401
+ type: opts.type,
402
+ content: opts.content,
403
+ expectsReply: opts.expectsReply ?? false,
404
+ replyTo: opts.replyTo ?? null,
405
+ };
406
+
407
+ const finalFilename = `${id}.msg.json`;
408
+ const tempFilename = `${id}.msg.json.tmp`;
409
+ const tempPath = join(outboxDir, tempFilename);
410
+ const finalPath = join(outboxDir, finalFilename);
411
+
412
+ try {
413
+ writeFileSync(tempPath, JSON.stringify(message, null, 2) + "\n", "utf-8");
414
+ renameSync(tempPath, finalPath);
415
+ } catch (err) {
416
+ try { if (existsSync(tempPath)) unlinkSync(tempPath); } catch { /* cleanup */ }
417
+ throw new Error(`Failed to write outbox message: ${err instanceof Error ? err.message : String(err)}`);
418
+ }
419
+
420
+ return message;
421
+ }
422
+
423
+ /**
424
+ * Read pending outbox messages from an agent's outbox directory.
425
+ *
426
+ * @param stateRoot - Root directory containing .pi/
427
+ * @param batchId - Batch ID
428
+ * @param agentId - Agent ID whose outbox to read
429
+ * @returns Array of outbox messages sorted by timestamp
430
+ *
431
+ * @since TP-106
432
+ */
433
+ export function readOutbox(
434
+ stateRoot: string,
435
+ batchId: string,
436
+ agentId: string,
437
+ ): MailboxMessage[] {
438
+ const outboxDir = sessionOutboxDir(stateRoot, batchId, agentId);
439
+ if (!existsSync(outboxDir)) return [];
440
+
441
+ let entries: string[];
442
+ try {
443
+ entries = readdirSync(outboxDir);
444
+ } catch {
445
+ return [];
446
+ }
447
+
448
+ const msgFiles = entries.filter(f => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp"));
449
+ const messages: MailboxMessage[] = [];
450
+
451
+ for (const filename of msgFiles) {
452
+ try {
453
+ const raw = readFileSync(join(outboxDir, filename), "utf-8");
454
+ const parsed = JSON.parse(raw);
455
+ if (isValidMailboxMessage(parsed)) {
456
+ messages.push(parsed);
457
+ }
458
+ } catch { /* skip malformed */ }
459
+ }
460
+
461
+ messages.sort((a, b) => a.timestamp - b.timestamp);
462
+ return messages;
463
+ }
464
+
465
+ /**
466
+ * Read all outbox messages (pending + processed) for durable history.
467
+ *
468
+ * Unlike readOutbox() which only reads pending messages, this function
469
+ * also reads outbox/processed/ so consumed replies remain visible to
470
+ * the supervisor via read_agent_replies.
471
+ *
472
+ * @param stateRoot - Root directory containing .pi/
473
+ * @param batchId - Batch ID
474
+ * @param agentId - Agent ID whose outbox history to read
475
+ * @returns Array of { message, acked } sorted by timestamp
476
+ *
477
+ * @since TP-091
478
+ */
479
+ export function readOutboxHistory(
480
+ stateRoot: string,
481
+ batchId: string,
482
+ agentId: string,
483
+ ): Array<{ message: MailboxMessage; acked: boolean }> {
484
+ const outboxDir = sessionOutboxDir(stateRoot, batchId, agentId);
485
+ const results: Array<{ message: MailboxMessage; acked: boolean }> = [];
486
+
487
+ for (const [dir, acked] of [[outboxDir, false], [join(outboxDir, "processed"), true]] as const) {
488
+ if (!existsSync(dir)) continue;
489
+ let entries: string[];
490
+ try { entries = readdirSync(dir); } catch { continue; }
491
+
492
+ const msgFiles = entries.filter(f => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp"));
493
+ for (const filename of msgFiles) {
494
+ try {
495
+ const raw = readFileSync(join(dir, filename), "utf-8");
496
+ const parsed = JSON.parse(raw);
497
+ if (isValidMailboxMessage(parsed)) {
498
+ results.push({ message: parsed, acked });
499
+ }
500
+ } catch { /* skip malformed */ }
501
+ }
502
+ }
503
+
504
+ results.sort((a, b) => a.message.timestamp - b.message.timestamp);
505
+ return results;
506
+ }
507
+
508
+ /**
509
+ * Ack (consume) a specific outbox message by moving it to processed/.
510
+ *
511
+ * Returns false if the message is already gone (race-safe/idempotent).
512
+ *
513
+ * @since TP-106
514
+ */
515
+ export function ackOutboxMessage(
516
+ stateRoot: string,
517
+ batchId: string,
518
+ agentId: string,
519
+ messageId: string,
520
+ ): boolean {
521
+ const outboxDir = sessionOutboxDir(stateRoot, batchId, agentId);
522
+ const processedDir = join(outboxDir, "processed");
523
+ const file = `${messageId}.msg.json`;
524
+ const srcPath = join(outboxDir, file);
525
+ const dstPath = join(processedDir, file);
526
+
527
+ try {
528
+ mkdirSync(processedDir, { recursive: true });
529
+ renameSync(srcPath, dstPath);
530
+ return true;
531
+ } catch (err: unknown) {
532
+ const code = (err as NodeJS.ErrnoException).code;
533
+ if (code === "ENOENT") return false;
534
+ process.stderr.write(
535
+ `[mailbox] WARNING: failed to ack outbox ${file}: ${err instanceof Error ? err.message : String(err)}\n`,
536
+ );
537
+ return false;
538
+ }
539
+ }
540
+
541
+ /**
542
+ * Discover all agent IDs that have mailbox directories for a batch.
543
+ * Returns directory names under .pi/mailbox/{batchId}/ excluding _broadcast.
544
+ * Used to find agents with historical messages even if no longer in the registry.
545
+ *
546
+ * @param stateRoot - Root directory containing .pi/
547
+ * @param batchId - Batch ID
548
+ * @returns Array of agent IDs found in mailbox directories
549
+ *
550
+ * @since TP-091
551
+ */
552
+ export function discoverMailboxAgentIds(
553
+ stateRoot: string,
554
+ batchId: string,
555
+ ): string[] {
556
+ const mbRoot = join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId);
557
+ if (!existsSync(mbRoot)) return [];
558
+ try {
559
+ const entries = readdirSync(mbRoot, { withFileTypes: true });
560
+ return entries
561
+ .filter(e => e.isDirectory() && e.name !== "_broadcast")
562
+ .map(e => e.name);
563
+ } catch {
564
+ return [];
565
+ }
566
+ }
567
+
568
+
569
+ export type MailboxAuditEventType =
570
+ | "message_sent"
571
+ | "message_delivered"
572
+ | "message_replied"
573
+ | "message_escalated"
574
+ | "message_rate_limited";
575
+
576
+ /**
577
+ * Append a mailbox audit event to .pi/mailbox/{batchId}/events.jsonl.
578
+ *
579
+ * Best-effort: logs warning but never throws.
580
+ *
581
+ * @since TP-106
582
+ */
583
+ export function appendMailboxAuditEvent(
584
+ stateRoot: string,
585
+ batchId: string,
586
+ event: {
587
+ type: MailboxAuditEventType;
588
+ ts?: number;
589
+ from?: string;
590
+ to?: string;
591
+ messageId?: string;
592
+ messageType?: string;
593
+ contentPreview?: string;
594
+ broadcast?: boolean;
595
+ reason?: string;
596
+ retryAfterMs?: number;
597
+ },
598
+ ): void {
599
+ const eventsPath = join(mailboxRoot(stateRoot, batchId), "events.jsonl");
600
+ try {
601
+ mkdirSync(dirname(eventsPath), { recursive: true });
602
+ appendFileSync(
603
+ eventsPath,
604
+ JSON.stringify({ batchId, ts: event.ts ?? Date.now(), ...event }) + "\n",
605
+ "utf-8",
606
+ );
607
+ } catch (err) {
608
+ process.stderr.write(
609
+ `[mailbox] WARNING: failed to append mailbox event: ${err instanceof Error ? err.message : String(err)}\n`,
610
+ );
611
+ }
612
+ }
613
+
614
+
615
+ // ── Broadcast (TP-106) ────────────────────────────────────────
616
+
617
+ /**
618
+ * Write a broadcast message to all agents.
619
+ *
620
+ * The message is written to `_broadcast/inbox/`. Agent hosts check
621
+ * this directory alongside their own inbox on each `message_end`.
622
+ *
623
+ * @param stateRoot - Root directory containing .pi/
624
+ * @param batchId - Current batch ID
625
+ * @param opts - Message content and metadata
626
+ * @returns The written MailboxMessage
627
+ *
628
+ * @since TP-106
629
+ */
630
+ export function writeBroadcastMessage(
631
+ stateRoot: string,
632
+ batchId: string,
633
+ opts: WriteMailboxMessageOpts,
634
+ ): MailboxMessage {
635
+ return writeMailboxMessage(stateRoot, batchId, "_broadcast", {
636
+ ...opts,
637
+ from: opts.from || "supervisor",
638
+ });
639
+ }
640
+
641
+
642
+ // ── Rate Limiting (TP-106) ─────────────────────────────────────
643
+
644
+ /** Default rate limit: max 1 message per agent per 30 seconds. */
645
+ export const RATE_LIMIT_WINDOW_MS = 30_000;
646
+
647
+ /** In-memory rate limit tracker. Keyed by target agent ID. */
648
+ const rateLimitTracker = new Map<string, number>();
649
+
650
+ /**
651
+ * Check whether sending a message to a target is rate-limited.
652
+ *
653
+ * @param targetAgentId - Agent ID being sent to
654
+ * @param windowMs - Rate limit window in ms (default: 30_000)
655
+ * @returns Object with `allowed` and optional `retryAfterMs`
656
+ *
657
+ * @since TP-106
658
+ */
659
+ export function checkRateLimit(
660
+ targetAgentId: string,
661
+ windowMs: number = RATE_LIMIT_WINDOW_MS,
662
+ ): { allowed: boolean; retryAfterMs?: number } {
663
+ const lastSent = rateLimitTracker.get(targetAgentId);
664
+ if (!lastSent) return { allowed: true };
665
+
666
+ const elapsed = Date.now() - lastSent;
667
+ if (elapsed >= windowMs) return { allowed: true };
668
+
669
+ return { allowed: false, retryAfterMs: windowMs - elapsed };
670
+ }
671
+
672
+ /**
673
+ * Record a send timestamp for rate limiting.
674
+ *
675
+ * @param targetAgentId - Agent ID that was sent to
676
+ *
677
+ * @since TP-106
678
+ */
679
+ export function recordSend(targetAgentId: string): void {
680
+ rateLimitTracker.set(targetAgentId, Date.now());
681
+ }
682
+
683
+ /**
684
+ * Reset rate limit state (for testing).
685
+ * @since TP-106
686
+ */
687
+ export function _resetRateLimits(): void {
688
+ rateLimitTracker.clear();
689
+ }