@pi-agents/orchid 0.1.0-beta.0

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 (163) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/LICENSE +21 -0
  3. package/README.md +246 -0
  4. package/agents/AGENTS-MANIFEST.md +42 -0
  5. package/agents/brain.md +42 -0
  6. package/agents/context-builder.md +46 -0
  7. package/agents/delegate.md +12 -0
  8. package/agents/dev-1.md +42 -0
  9. package/agents/oracle.md +73 -0
  10. package/agents/planner.md +55 -0
  11. package/agents/researcher.md +52 -0
  12. package/agents/reviewer.md +79 -0
  13. package/agents/scout.md +50 -0
  14. package/agents/tester.md +45 -0
  15. package/agents/worker.md +55 -0
  16. package/extensions/ralph.ts +1 -0
  17. package/extensions/reviewer-extension.ts +125 -0
  18. package/extensions/task-orchestrator.ts +28 -0
  19. package/package.json +63 -0
  20. package/prompts/gather-context-and-clarify.md +13 -0
  21. package/prompts/parallel-cleanup.md +59 -0
  22. package/prompts/parallel-context-build.md +53 -0
  23. package/prompts/parallel-handoff-plan.md +59 -0
  24. package/prompts/parallel-research.md +50 -0
  25. package/prompts/parallel-review.md +54 -0
  26. package/prompts/review-loop.md +41 -0
  27. package/skills/orchid/SKILL.md +214 -0
  28. package/skills/orchid/orchid-cleanup/SKILL.md +122 -0
  29. package/skills/orchid/orchid-converge/SKILL.md +124 -0
  30. package/skills/orchid/orchid-decompose/SKILL.md +201 -0
  31. package/skills/orchid/orchid-doctor/SKILL.md +162 -0
  32. package/skills/orchid/orchid-investigate/SKILL.md +102 -0
  33. package/skills/orchid/orchid-launch/SKILL.md +147 -0
  34. package/skills/ralph/SKILL.md +73 -0
  35. package/skills/subagents/pi-subagents/SKILL.md +813 -0
  36. package/src/index.ts +7 -0
  37. package/src/orchestrator/abort.ts +534 -0
  38. package/src/orchestrator/agent-bridge-extension.ts +1020 -0
  39. package/src/orchestrator/agent-host.ts +954 -0
  40. package/src/orchestrator/cleanup.ts +776 -0
  41. package/src/orchestrator/config-loader.ts +1412 -0
  42. package/src/orchestrator/config-schema.ts +690 -0
  43. package/src/orchestrator/config.ts +81 -0
  44. package/src/orchestrator/context-window.ts +66 -0
  45. package/src/orchestrator/diagnostic-reports.ts +475 -0
  46. package/src/orchestrator/diagnostics.ts +394 -0
  47. package/src/orchestrator/discovery.ts +1833 -0
  48. package/src/orchestrator/engine-worker.ts +415 -0
  49. package/src/orchestrator/engine.ts +5940 -0
  50. package/src/orchestrator/execution.ts +3104 -0
  51. package/src/orchestrator/extension.ts +5934 -0
  52. package/src/orchestrator/formatting.ts +785 -0
  53. package/src/orchestrator/git.ts +88 -0
  54. package/src/orchestrator/index.ts +28 -0
  55. package/src/orchestrator/lane-runner.ts +1787 -0
  56. package/src/orchestrator/mailbox.ts +780 -0
  57. package/src/orchestrator/merge.ts +3414 -0
  58. package/src/orchestrator/messages.ts +1062 -0
  59. package/src/orchestrator/migrations.ts +278 -0
  60. package/src/orchestrator/naming.ts +117 -0
  61. package/src/orchestrator/path-resolver.ts +275 -0
  62. package/src/orchestrator/persistence.ts +2625 -0
  63. package/src/orchestrator/process-registry.ts +452 -0
  64. package/src/orchestrator/quality-gate.ts +1085 -0
  65. package/src/orchestrator/resume.ts +3488 -0
  66. package/src/orchestrator/sessions.ts +57 -0
  67. package/src/orchestrator/settings-loader.ts +136 -0
  68. package/src/orchestrator/settings-tui.ts +2208 -0
  69. package/src/orchestrator/sidecar-telemetry.ts +267 -0
  70. package/src/orchestrator/supervisor.ts +4548 -0
  71. package/src/orchestrator/task-executor-core.ts +675 -0
  72. package/src/orchestrator/tmux-compat.ts +37 -0
  73. package/src/orchestrator/tool-allowlist-constants.ts +37 -0
  74. package/src/orchestrator/types.ts +4465 -0
  75. package/src/orchestrator/verification.ts +547 -0
  76. package/src/orchestrator/waves.ts +1564 -0
  77. package/src/orchestrator/workspace.ts +707 -0
  78. package/src/orchestrator/worktree.ts +2725 -0
  79. package/src/ralph/index.ts +825 -0
  80. package/src/subagents/agents/agent-management.ts +648 -0
  81. package/src/subagents/agents/agent-scope.ts +6 -0
  82. package/src/subagents/agents/agent-selection.ts +23 -0
  83. package/src/subagents/agents/agent-serializer.ts +86 -0
  84. package/src/subagents/agents/agents.ts +832 -0
  85. package/src/subagents/agents/chain-serializer.ts +137 -0
  86. package/src/subagents/agents/frontmatter.ts +29 -0
  87. package/src/subagents/agents/identity.ts +30 -0
  88. package/src/subagents/agents/skills.ts +632 -0
  89. package/src/subagents/extension/config.ts +16 -0
  90. package/src/subagents/extension/control-notices.ts +92 -0
  91. package/src/subagents/extension/doctor.ts +199 -0
  92. package/src/subagents/extension/fanout-child.ts +170 -0
  93. package/src/subagents/extension/index.ts +573 -0
  94. package/src/subagents/extension/schemas.ts +168 -0
  95. package/src/subagents/intercom/intercom-bridge.ts +379 -0
  96. package/src/subagents/intercom/result-intercom.ts +377 -0
  97. package/src/subagents/runs/background/async-execution.ts +712 -0
  98. package/src/subagents/runs/background/async-job-tracker.ts +310 -0
  99. package/src/subagents/runs/background/async-resume.ts +345 -0
  100. package/src/subagents/runs/background/async-status.ts +325 -0
  101. package/src/subagents/runs/background/completion-dedupe.ts +63 -0
  102. package/src/subagents/runs/background/notify.ts +108 -0
  103. package/src/subagents/runs/background/parallel-groups.ts +45 -0
  104. package/src/subagents/runs/background/result-watcher.ts +307 -0
  105. package/src/subagents/runs/background/run-id-resolver.ts +83 -0
  106. package/src/subagents/runs/background/run-status.ts +269 -0
  107. package/src/subagents/runs/background/stale-run-reconciler.ts +336 -0
  108. package/src/subagents/runs/background/subagent-runner.ts +1808 -0
  109. package/src/subagents/runs/background/top-level-async.ts +13 -0
  110. package/src/subagents/runs/foreground/chain-clarify.ts +1333 -0
  111. package/src/subagents/runs/foreground/chain-execution.ts +938 -0
  112. package/src/subagents/runs/foreground/execution.ts +918 -0
  113. package/src/subagents/runs/foreground/subagent-executor.ts +2527 -0
  114. package/src/subagents/runs/shared/completion-guard.ts +147 -0
  115. package/src/subagents/runs/shared/long-running-guard.ts +175 -0
  116. package/src/subagents/runs/shared/mcp-direct-tool-allowlist.ts +365 -0
  117. package/src/subagents/runs/shared/model-fallback.ts +103 -0
  118. package/src/subagents/runs/shared/nested-events.ts +819 -0
  119. package/src/subagents/runs/shared/nested-path.ts +52 -0
  120. package/src/subagents/runs/shared/nested-render.ts +115 -0
  121. package/src/subagents/runs/shared/parallel-utils.ts +109 -0
  122. package/src/subagents/runs/shared/pi-args.ts +220 -0
  123. package/src/subagents/runs/shared/pi-spawn.ts +115 -0
  124. package/src/subagents/runs/shared/run-history.ts +60 -0
  125. package/src/subagents/runs/shared/single-output.ts +164 -0
  126. package/src/subagents/runs/shared/subagent-control.ts +226 -0
  127. package/src/subagents/runs/shared/subagent-prompt-runtime.ts +170 -0
  128. package/src/subagents/runs/shared/worktree.ts +577 -0
  129. package/src/subagents/shared/artifacts.ts +98 -0
  130. package/src/subagents/shared/atomic-json.ts +16 -0
  131. package/src/subagents/shared/file-coalescer.ts +40 -0
  132. package/src/subagents/shared/fork-context.ts +76 -0
  133. package/src/subagents/shared/formatters.ts +133 -0
  134. package/src/subagents/shared/jsonl-writer.ts +81 -0
  135. package/src/subagents/shared/model-info.ts +78 -0
  136. package/src/subagents/shared/post-exit-stdio-guard.ts +85 -0
  137. package/src/subagents/shared/session-identity.ts +10 -0
  138. package/src/subagents/shared/session-tokens.ts +44 -0
  139. package/src/subagents/shared/settings.ts +397 -0
  140. package/src/subagents/shared/status-format.ts +49 -0
  141. package/src/subagents/shared/types.ts +822 -0
  142. package/src/subagents/shared/utils.ts +450 -0
  143. package/src/subagents/slash/prompt-template-bridge.ts +397 -0
  144. package/src/subagents/slash/slash-bridge.ts +174 -0
  145. package/src/subagents/slash/slash-commands.ts +528 -0
  146. package/src/subagents/slash/slash-live-state.ts +292 -0
  147. package/src/subagents/tui/render-helpers.ts +80 -0
  148. package/src/subagents/tui/render.ts +1358 -0
  149. package/templates/agents/local/supervisor.md +33 -0
  150. package/templates/agents/local/task-merger.md +27 -0
  151. package/templates/agents/local/task-reviewer.md +30 -0
  152. package/templates/agents/local/task-worker.md +34 -0
  153. package/templates/agents/supervisor-routing.md +92 -0
  154. package/templates/agents/supervisor.md +229 -0
  155. package/templates/agents/task-merger.md +214 -0
  156. package/templates/agents/task-reviewer.md +260 -0
  157. package/templates/agents/task-worker-segment.md +44 -0
  158. package/templates/agents/task-worker.md +557 -0
  159. package/templates/tasks/CONTEXT.md +30 -0
  160. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -0
  161. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -0
  162. package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -0
  163. package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -0
@@ -0,0 +1,780 @@
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 {
28
+ existsSync,
29
+ mkdirSync,
30
+ writeFileSync,
31
+ readFileSync,
32
+ readdirSync,
33
+ renameSync,
34
+ unlinkSync,
35
+ appendFileSync,
36
+ } from "fs";
37
+ import { randomBytes } from "crypto";
38
+ import type { MailboxMessage, MailboxMessageType, WriteMailboxMessageOpts } from "./types.ts";
39
+ import { MAILBOX_DIR_NAME, MAILBOX_MAX_CONTENT_BYTES, MAILBOX_MESSAGE_TYPES } from "./types.ts";
40
+
41
+ // ── Path Helpers ─────────────────────────────────────────────────────
42
+
43
+ /**
44
+ * Root directory for all mailboxes in a batch.
45
+ *
46
+ * @param stateRoot - Root directory containing .pi/ (workspace root or repo root)
47
+ * @param batchId - Batch ID for scoping
48
+ * @returns Absolute path: `{stateRoot}/.pi/mailbox/{batchId}/`
49
+ *
50
+ * @since TP-089
51
+ */
52
+ export function mailboxRoot(stateRoot: string, batchId: string): string {
53
+ return join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId);
54
+ }
55
+
56
+ /**
57
+ * Inbox directory for a specific agent session.
58
+ *
59
+ * @param stateRoot - Root directory containing .pi/
60
+ * @param batchId - Batch ID
61
+ * @param sessionName - tmux session name (unique per batch)
62
+ * @returns Absolute path: `{stateRoot}/.pi/mailbox/{batchId}/{sessionName}/inbox/`
63
+ *
64
+ * @since TP-089
65
+ */
66
+ export function sessionInboxDir(stateRoot: string, batchId: string, sessionName: string): string {
67
+ return join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId, sessionName, "inbox");
68
+ }
69
+
70
+ /**
71
+ * Ack directory for a specific agent session.
72
+ *
73
+ * @param stateRoot - Root directory containing .pi/
74
+ * @param batchId - Batch ID
75
+ * @param sessionName - tmux session name
76
+ * @returns Absolute path: `{stateRoot}/.pi/mailbox/{batchId}/{sessionName}/ack/`
77
+ *
78
+ * @since TP-089
79
+ */
80
+ export function sessionAckDir(stateRoot: string, batchId: string, sessionName: string): string {
81
+ return join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId, sessionName, "ack");
82
+ }
83
+
84
+ /**
85
+ * Broadcast inbox directory (messages to all agents).
86
+ *
87
+ * @param stateRoot - Root directory containing .pi/
88
+ * @param batchId - Batch ID
89
+ * @returns Absolute path: `{stateRoot}/.pi/mailbox/{batchId}/_broadcast/inbox/`
90
+ *
91
+ * @since TP-089
92
+ */
93
+ export function broadcastInboxDir(stateRoot: string, batchId: string): string {
94
+ return join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId, "_broadcast", "inbox");
95
+ }
96
+
97
+ // ── Write ────────────────────────────────────────────────────────────
98
+
99
+ /**
100
+ * Write a message to a target agent's inbox.
101
+ *
102
+ * Generates a unique message ID and writes the message atomically
103
+ * (temp file + rename in the same directory). The temp file uses a
104
+ * `.msg.json.tmp` extension that is excluded by the inbox reader's
105
+ * `*.msg.json` filter.
106
+ *
107
+ * @param stateRoot - Root directory containing .pi/
108
+ * @param batchId - Current batch ID
109
+ * @param to - Target session name or `"_broadcast"`
110
+ * @param opts - Message content and metadata from the caller
111
+ * @returns The written MailboxMessage (including generated fields)
112
+ * @throws If content exceeds 4KB UTF-8 bytes or file I/O fails
113
+ *
114
+ * @since TP-089
115
+ */
116
+ export function writeMailboxMessage(
117
+ stateRoot: string,
118
+ batchId: string,
119
+ to: string,
120
+ opts: WriteMailboxMessageOpts,
121
+ ): MailboxMessage {
122
+ // Validate content size (UTF-8 bytes, not string length)
123
+ const contentBytes = Buffer.byteLength(opts.content, "utf8");
124
+ if (contentBytes > MAILBOX_MAX_CONTENT_BYTES) {
125
+ throw new Error(
126
+ `Mailbox message content exceeds ${MAILBOX_MAX_CONTENT_BYTES} byte limit ` +
127
+ `(${contentBytes} bytes). Steering messages should be concise directives. ` +
128
+ `Write larger context to a file and reference it by path.`,
129
+ );
130
+ }
131
+
132
+ // Generate unique message ID
133
+ const timestamp = Date.now();
134
+ const nonce = randomBytes(3).toString("hex").slice(0, 5);
135
+ const id = `${timestamp}-${nonce}`;
136
+
137
+ // Build the full message
138
+ const message: MailboxMessage = {
139
+ id,
140
+ batchId,
141
+ from: opts.from,
142
+ to,
143
+ timestamp,
144
+ type: opts.type,
145
+ content: opts.content,
146
+ expectsReply: opts.expectsReply ?? false,
147
+ replyTo: opts.replyTo ?? null,
148
+ };
149
+
150
+ // Determine inbox directory
151
+ const inboxDir =
152
+ to === "_broadcast"
153
+ ? broadcastInboxDir(stateRoot, batchId)
154
+ : sessionInboxDir(stateRoot, batchId, to);
155
+
156
+ // Ensure inbox directory exists
157
+ mkdirSync(inboxDir, { recursive: true });
158
+
159
+ // Atomic write: temp file (.msg.json.tmp) then rename to final (.msg.json)
160
+ const finalFilename = `${id}.msg.json`;
161
+ const tempFilename = `${id}.msg.json.tmp`;
162
+ const tempPath = join(inboxDir, tempFilename);
163
+ const finalPath = join(inboxDir, finalFilename);
164
+
165
+ try {
166
+ writeFileSync(tempPath, JSON.stringify(message, null, 2) + "\n", "utf-8");
167
+ renameSync(tempPath, finalPath);
168
+ } catch (err) {
169
+ // Attempt cleanup of temp file on failure
170
+ try {
171
+ if (existsSync(tempPath)) unlinkSync(tempPath);
172
+ } catch {
173
+ // Best effort cleanup
174
+ }
175
+ throw new Error(
176
+ `Failed to write mailbox message to ${finalPath}: ${err instanceof Error ? err.message : String(err)}`,
177
+ );
178
+ }
179
+
180
+ return message;
181
+ }
182
+
183
+ // ── Read ─────────────────────────────────────────────────────────────
184
+
185
+ /**
186
+ * Read pending messages from an inbox directory.
187
+ *
188
+ * Returns messages sorted by timestamp (ascending), with filename
189
+ * lexical order as tie-breaker. Only reads files matching the
190
+ * `*.msg.json` pattern (excludes `.msg.json.tmp` temp files).
191
+ *
192
+ * Messages with invalid shape or mismatched batchId are logged as
193
+ * warnings and left in the inbox (no throw/crash).
194
+ *
195
+ * @param inboxDir - Absolute path to the inbox directory
196
+ * @param expectedBatchId - Expected batch ID for validation
197
+ * @returns Sorted array of `{ filename, message }` entries
198
+ *
199
+ * @since TP-089
200
+ */
201
+ export function readInbox(
202
+ inboxDir: string,
203
+ expectedBatchId: string,
204
+ ): Array<{ filename: string; message: MailboxMessage }> {
205
+ // Return empty if directory doesn't exist
206
+ if (!existsSync(inboxDir)) return [];
207
+
208
+ let entries: string[];
209
+ try {
210
+ entries = readdirSync(inboxDir);
211
+ } catch (err) {
212
+ process.stderr.write(
213
+ `[mailbox] WARNING: failed to read inbox ${inboxDir}: ${err instanceof Error ? err.message : String(err)}\n`,
214
+ );
215
+ return [];
216
+ }
217
+
218
+ // Filter: only *.msg.json files (excludes .msg.json.tmp, .tmp, etc.)
219
+ const msgFiles = entries.filter((f) => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp"));
220
+
221
+ const results: Array<{ filename: string; message: MailboxMessage }> = [];
222
+
223
+ for (const filename of msgFiles) {
224
+ const filePath = join(inboxDir, filename);
225
+ let raw: string;
226
+ try {
227
+ raw = readFileSync(filePath, "utf-8");
228
+ } catch (err) {
229
+ process.stderr.write(
230
+ `[mailbox] WARNING: failed to read ${filePath}: ${err instanceof Error ? err.message : String(err)}\n`,
231
+ );
232
+ continue;
233
+ }
234
+
235
+ let parsed: unknown;
236
+ try {
237
+ parsed = JSON.parse(raw);
238
+ } catch {
239
+ process.stderr.write(`[mailbox] WARNING: malformed JSON in ${filename}, skipping\n`);
240
+ continue;
241
+ }
242
+
243
+ // Validate shape
244
+ if (!isValidMailboxMessage(parsed)) {
245
+ process.stderr.write(`[mailbox] WARNING: invalid message shape in ${filename}, skipping\n`);
246
+ continue;
247
+ }
248
+
249
+ const msg = parsed as MailboxMessage;
250
+
251
+ // Validate batchId
252
+ if (msg.batchId !== expectedBatchId) {
253
+ process.stderr.write(
254
+ `[mailbox] WARNING: batchId mismatch in ${filename} (expected ${expectedBatchId}, got ${msg.batchId}), skipping\n`,
255
+ );
256
+ continue;
257
+ }
258
+
259
+ results.push({ filename, message: msg });
260
+ }
261
+
262
+ // Sort: primary by timestamp (ascending), tie-break by filename lexical
263
+ results.sort((a, b) => {
264
+ const tsDiff = a.message.timestamp - b.message.timestamp;
265
+ if (tsDiff !== 0) return tsDiff;
266
+ return a.filename.localeCompare(b.filename);
267
+ });
268
+
269
+ return results;
270
+ }
271
+
272
+ // ── Acknowledge ──────────────────────────────────────────────────────
273
+
274
+ /**
275
+ * Move a message from inbox to ack directory.
276
+ *
277
+ * Atomic rename. If the file is already gone (another process acked it),
278
+ * returns false. The ack directory is derived structurally from the inbox
279
+ * directory: `dirname(inboxDir)/ack/`.
280
+ *
281
+ * @param inboxDir - Absolute path to the inbox directory
282
+ * @param filename - Message filename (e.g., `1774744971303-a7f2c.msg.json`)
283
+ * @returns true if acked successfully, false if already acked (ENOENT race)
284
+ *
285
+ * @since TP-089
286
+ */
287
+ export function ackMessage(inboxDir: string, filename: string): boolean {
288
+ const ackDir = join(dirname(inboxDir), "ack");
289
+
290
+ try {
291
+ mkdirSync(ackDir, { recursive: true });
292
+ } catch (err) {
293
+ process.stderr.write(
294
+ `[mailbox] WARNING: failed to create ack dir ${ackDir}: ${err instanceof Error ? err.message : String(err)}\n`,
295
+ );
296
+ return false;
297
+ }
298
+
299
+ const srcPath = join(inboxDir, filename);
300
+ const dstPath = join(ackDir, filename);
301
+
302
+ try {
303
+ renameSync(srcPath, dstPath);
304
+ return true;
305
+ } catch (err: unknown) {
306
+ const code = (err as NodeJS.ErrnoException).code;
307
+ if (code === "ENOENT") {
308
+ // Another process already acked this message — race is harmless
309
+ return false;
310
+ }
311
+ process.stderr.write(
312
+ `[mailbox] WARNING: failed to ack ${filename}: ${err instanceof Error ? err.message : String(err)}\n`,
313
+ );
314
+ return false;
315
+ }
316
+ }
317
+
318
+ // ── Validation ───────────────────────────────────────────────────────
319
+
320
+ /**
321
+ * Runtime validation for mailbox message shape.
322
+ *
323
+ * Checks that all required fields are present and correctly typed.
324
+ * Does not validate batchId match (caller's responsibility).
325
+ *
326
+ * @param obj - Parsed JSON value to validate
327
+ * @returns true if obj is a valid MailboxMessage shape
328
+ *
329
+ * @since TP-089
330
+ */
331
+ export function isValidMailboxMessage(obj: unknown): obj is MailboxMessage {
332
+ if (!obj || typeof obj !== "object") return false;
333
+ const m = obj as Record<string, unknown>;
334
+ return (
335
+ typeof m.id === "string" &&
336
+ typeof m.batchId === "string" &&
337
+ typeof m.from === "string" &&
338
+ typeof m.to === "string" &&
339
+ typeof m.timestamp === "number" &&
340
+ Number.isFinite(m.timestamp) &&
341
+ typeof m.type === "string" &&
342
+ MAILBOX_MESSAGE_TYPES.has(m.type) &&
343
+ typeof m.content === "string"
344
+ );
345
+ }
346
+
347
+ // ── Outbox (Agent → Supervisor, TP-106) ─────────────────────────
348
+
349
+ /**
350
+ * Outbox directory for a specific agent session.
351
+ *
352
+ * @param stateRoot - Root directory containing .pi/
353
+ * @param batchId - Batch ID
354
+ * @param sessionName - Agent ID / session name
355
+ * @returns Absolute path: `{stateRoot}/.pi/mailbox/{batchId}/{sessionName}/outbox/`
356
+ *
357
+ * @since TP-106
358
+ */
359
+ export function sessionOutboxDir(stateRoot: string, batchId: string, sessionName: string): string {
360
+ return join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId, sessionName, "outbox");
361
+ }
362
+
363
+ /**
364
+ * Write a reply or escalation message to an agent's outbox.
365
+ *
366
+ * Used by agents (via bridge tools or direct write) to communicate
367
+ * back to the supervisor. The engine or lane-runner polls outbox
368
+ * directories and surfaces messages as supervisor alerts.
369
+ *
370
+ * @param stateRoot - Root directory containing .pi/
371
+ * @param batchId - Current batch ID
372
+ * @param from - Agent ID writing the message
373
+ * @param opts - Message content and metadata
374
+ * @returns The written MailboxMessage
375
+ *
376
+ * @since TP-106
377
+ */
378
+ export function writeOutboxMessage(
379
+ stateRoot: string,
380
+ batchId: string,
381
+ from: string,
382
+ opts: WriteMailboxMessageOpts,
383
+ ): MailboxMessage {
384
+ const outboxDir = sessionOutboxDir(stateRoot, batchId, from);
385
+ mkdirSync(outboxDir, { recursive: true });
386
+
387
+ const contentBytes = Buffer.byteLength(opts.content, "utf8");
388
+ if (contentBytes > MAILBOX_MAX_CONTENT_BYTES) {
389
+ throw new Error(
390
+ `Outbox message content exceeds ${MAILBOX_MAX_CONTENT_BYTES} byte limit (${contentBytes} bytes).`,
391
+ );
392
+ }
393
+
394
+ const timestamp = Date.now();
395
+ const nonce = randomBytes(3).toString("hex").slice(0, 5);
396
+ const id = `${timestamp}-${nonce}`;
397
+
398
+ const message: MailboxMessage = {
399
+ id,
400
+ batchId,
401
+ from,
402
+ to: "supervisor",
403
+ timestamp,
404
+ type: opts.type,
405
+ content: opts.content,
406
+ expectsReply: opts.expectsReply ?? false,
407
+ replyTo: opts.replyTo ?? null,
408
+ };
409
+
410
+ const finalFilename = `${id}.msg.json`;
411
+ const tempFilename = `${id}.msg.json.tmp`;
412
+ const tempPath = join(outboxDir, tempFilename);
413
+ const finalPath = join(outboxDir, finalFilename);
414
+
415
+ try {
416
+ writeFileSync(tempPath, JSON.stringify(message, null, 2) + "\n", "utf-8");
417
+ renameSync(tempPath, finalPath);
418
+ } catch (err) {
419
+ try {
420
+ if (existsSync(tempPath)) unlinkSync(tempPath);
421
+ } catch {
422
+ /* cleanup */
423
+ }
424
+ throw new Error(
425
+ `Failed to write outbox message: ${err instanceof Error ? err.message : String(err)}`,
426
+ );
427
+ }
428
+
429
+ return message;
430
+ }
431
+
432
+ /**
433
+ * Read pending outbox messages from an agent's outbox directory.
434
+ *
435
+ * @param stateRoot - Root directory containing .pi/
436
+ * @param batchId - Batch ID
437
+ * @param agentId - Agent ID whose outbox to read
438
+ * @returns Array of outbox messages sorted by timestamp
439
+ *
440
+ * @since TP-106
441
+ */
442
+ export function readOutbox(stateRoot: string, batchId: string, agentId: string): MailboxMessage[] {
443
+ const outboxDir = sessionOutboxDir(stateRoot, batchId, agentId);
444
+ if (!existsSync(outboxDir)) return [];
445
+
446
+ let entries: string[];
447
+ try {
448
+ entries = readdirSync(outboxDir);
449
+ } catch {
450
+ return [];
451
+ }
452
+
453
+ const msgFiles = entries.filter((f) => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp"));
454
+ const messages: MailboxMessage[] = [];
455
+
456
+ for (const filename of msgFiles) {
457
+ try {
458
+ const raw = readFileSync(join(outboxDir, filename), "utf-8");
459
+ const parsed = JSON.parse(raw);
460
+ if (isValidMailboxMessage(parsed)) {
461
+ messages.push(parsed);
462
+ }
463
+ } catch {
464
+ /* skip malformed */
465
+ }
466
+ }
467
+
468
+ messages.sort((a, b) => a.timestamp - b.timestamp);
469
+ return messages;
470
+ }
471
+
472
+ /**
473
+ * Read all outbox messages (pending + processed) for durable history.
474
+ *
475
+ * Unlike readOutbox() which only reads pending messages, this function
476
+ * also reads outbox/processed/ so consumed replies remain visible to
477
+ * the supervisor via read_agent_replies.
478
+ *
479
+ * @param stateRoot - Root directory containing .pi/
480
+ * @param batchId - Batch ID
481
+ * @param agentId - Agent ID whose outbox history to read
482
+ * @returns Array of { message, acked } sorted by timestamp
483
+ *
484
+ * @since TP-091
485
+ */
486
+ export function readOutboxHistory(
487
+ stateRoot: string,
488
+ batchId: string,
489
+ agentId: string,
490
+ ): Array<{ message: MailboxMessage; acked: boolean }> {
491
+ const outboxDir = sessionOutboxDir(stateRoot, batchId, agentId);
492
+ const results: Array<{ message: MailboxMessage; acked: boolean }> = [];
493
+
494
+ for (const [dir, acked] of [
495
+ [outboxDir, false],
496
+ [join(outboxDir, "processed"), true],
497
+ ] as const) {
498
+ if (!existsSync(dir)) continue;
499
+ let entries: string[];
500
+ try {
501
+ entries = readdirSync(dir);
502
+ } catch {
503
+ continue;
504
+ }
505
+
506
+ const msgFiles = entries.filter((f) => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp"));
507
+ for (const filename of msgFiles) {
508
+ try {
509
+ const raw = readFileSync(join(dir, filename), "utf-8");
510
+ const parsed = JSON.parse(raw);
511
+ if (isValidMailboxMessage(parsed)) {
512
+ results.push({ message: parsed, acked });
513
+ }
514
+ } catch {
515
+ /* skip malformed */
516
+ }
517
+ }
518
+ }
519
+
520
+ results.sort((a, b) => a.message.timestamp - b.message.timestamp);
521
+ return results;
522
+ }
523
+
524
+ /**
525
+ * Ack (consume) a specific outbox message by moving it to processed/.
526
+ *
527
+ * Returns false if the message is already gone (race-safe/idempotent).
528
+ *
529
+ * @since TP-106
530
+ */
531
+ export function ackOutboxMessage(
532
+ stateRoot: string,
533
+ batchId: string,
534
+ agentId: string,
535
+ messageId: string,
536
+ ): boolean {
537
+ const outboxDir = sessionOutboxDir(stateRoot, batchId, agentId);
538
+ const processedDir = join(outboxDir, "processed");
539
+ const file = `${messageId}.msg.json`;
540
+ const srcPath = join(outboxDir, file);
541
+ const dstPath = join(processedDir, file);
542
+
543
+ try {
544
+ mkdirSync(processedDir, { recursive: true });
545
+ renameSync(srcPath, dstPath);
546
+ return true;
547
+ } catch (err: unknown) {
548
+ const code = (err as NodeJS.ErrnoException).code;
549
+ if (code === "ENOENT") return false;
550
+ process.stderr.write(
551
+ `[mailbox] WARNING: failed to ack outbox ${file}: ${err instanceof Error ? err.message : String(err)}\n`,
552
+ );
553
+ return false;
554
+ }
555
+ }
556
+
557
+ /**
558
+ * Drain (purge to processed/) all pending outbox messages for an agent.
559
+ *
560
+ * Used at lane-termination decision points to ensure stale escalations or
561
+ * replies that the worker emitted just before termination don't get later
562
+ * re-discovered and re-forwarded as zombie supervisor alerts. The drain
563
+ * mirrors {@link ackOutboxMessage} — each pending `*.msg.json` file is
564
+ * moved to `outbox/processed/` so it remains in the durable history (for
565
+ * `read_agent_replies`) but is no longer pending.
566
+ *
567
+ * Best-effort: any per-file failure is logged but does not abort the drain.
568
+ * Returns the number of messages successfully drained.
569
+ *
570
+ * Also drains any non-message pending files in the outbox (e.g.,
571
+ * `segment-expansion-*.json` requests) by renaming them to a `.drained`
572
+ * sibling so the engine's discovery scans don't re-pick them up.
573
+ *
574
+ * @since TP-187 (#538)
575
+ */
576
+ export function drainAgentOutbox(stateRoot: string, batchId: string, agentId: string): number {
577
+ const outboxDir = sessionOutboxDir(stateRoot, batchId, agentId);
578
+ if (!existsSync(outboxDir)) return 0;
579
+
580
+ let entries: string[] = [];
581
+ try {
582
+ entries = readdirSync(outboxDir);
583
+ } catch (err) {
584
+ process.stderr.write(
585
+ `[mailbox] WARNING: drainAgentOutbox failed to read ${outboxDir}: ${err instanceof Error ? err.message : String(err)}\n`,
586
+ );
587
+ return 0;
588
+ }
589
+
590
+ let drained = 0;
591
+ const processedDir = join(outboxDir, "processed");
592
+ let processedDirEnsured = false;
593
+
594
+ for (const entry of entries) {
595
+ // Skip the processed/ subdirectory itself and any in-flight temp writes.
596
+ if (entry === "processed" || entry.endsWith(".tmp")) continue;
597
+
598
+ const srcPath = join(outboxDir, entry);
599
+
600
+ if (entry.endsWith(".msg.json")) {
601
+ if (!processedDirEnsured) {
602
+ try {
603
+ mkdirSync(processedDir, { recursive: true });
604
+ } catch {
605
+ /* fall through to rename error handling */
606
+ }
607
+ processedDirEnsured = true;
608
+ }
609
+ const dstPath = join(processedDir, entry);
610
+ try {
611
+ renameSync(srcPath, dstPath);
612
+ drained++;
613
+ } catch (err: unknown) {
614
+ const code = (err as NodeJS.ErrnoException).code;
615
+ if (code === "ENOENT") continue; // already gone — race-safe
616
+ process.stderr.write(
617
+ `[mailbox] WARNING: drainAgentOutbox failed to rename ${entry}: ${err instanceof Error ? err.message : String(err)}\n`,
618
+ );
619
+ }
620
+ continue;
621
+ }
622
+
623
+ // Non-message pending files (e.g., segment-expansion-*.json). Rename in
624
+ // place to a `.drained` suffix so engine.ts discovery scans skip them.
625
+ try {
626
+ renameSync(srcPath, `${srcPath}.drained`);
627
+ drained++;
628
+ } catch (err: unknown) {
629
+ const code = (err as NodeJS.ErrnoException).code;
630
+ if (code === "ENOENT") continue;
631
+ process.stderr.write(
632
+ `[mailbox] WARNING: drainAgentOutbox failed to mark ${entry} drained: ${err instanceof Error ? err.message : String(err)}\n`,
633
+ );
634
+ }
635
+ }
636
+
637
+ return drained;
638
+ }
639
+
640
+ /**
641
+ * Discover all agent IDs that have mailbox directories for a batch.
642
+ * Returns directory names under .pi/mailbox/{batchId}/ excluding _broadcast.
643
+ * Used to find agents with historical messages even if no longer in the registry.
644
+ *
645
+ * @param stateRoot - Root directory containing .pi/
646
+ * @param batchId - Batch ID
647
+ * @returns Array of agent IDs found in mailbox directories
648
+ *
649
+ * @since TP-091
650
+ */
651
+ export function discoverMailboxAgentIds(stateRoot: string, batchId: string): string[] {
652
+ const mbRoot = join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId);
653
+ if (!existsSync(mbRoot)) return [];
654
+ try {
655
+ const entries = readdirSync(mbRoot, { withFileTypes: true });
656
+ return entries.filter((e) => e.isDirectory() && e.name !== "_broadcast").map((e) => e.name);
657
+ } catch {
658
+ return [];
659
+ }
660
+ }
661
+
662
+ export type MailboxAuditEventType =
663
+ | "message_sent"
664
+ | "message_delivered"
665
+ | "message_replied"
666
+ | "message_escalated"
667
+ | "message_rate_limited";
668
+
669
+ /**
670
+ * Append a mailbox audit event to .pi/mailbox/{batchId}/events.jsonl.
671
+ *
672
+ * Best-effort: logs warning but never throws.
673
+ *
674
+ * @since TP-106
675
+ */
676
+ export function appendMailboxAuditEvent(
677
+ stateRoot: string,
678
+ batchId: string,
679
+ event: {
680
+ type: MailboxAuditEventType;
681
+ ts?: number;
682
+ from?: string;
683
+ to?: string;
684
+ messageId?: string;
685
+ messageType?: string;
686
+ contentPreview?: string;
687
+ broadcast?: boolean;
688
+ reason?: string;
689
+ retryAfterMs?: number;
690
+ },
691
+ ): void {
692
+ const eventsPath = join(mailboxRoot(stateRoot, batchId), "events.jsonl");
693
+ try {
694
+ mkdirSync(dirname(eventsPath), { recursive: true });
695
+ appendFileSync(
696
+ eventsPath,
697
+ JSON.stringify({ batchId, ts: event.ts ?? Date.now(), ...event }) + "\n",
698
+ "utf-8",
699
+ );
700
+ } catch (err) {
701
+ process.stderr.write(
702
+ `[mailbox] WARNING: failed to append mailbox event: ${err instanceof Error ? err.message : String(err)}\n`,
703
+ );
704
+ }
705
+ }
706
+
707
+ // ── Broadcast (TP-106) ────────────────────────────────────────
708
+
709
+ /**
710
+ * Write a broadcast message to all agents.
711
+ *
712
+ * The message is written to `_broadcast/inbox/`. Agent hosts check
713
+ * this directory alongside their own inbox on each `message_end`.
714
+ *
715
+ * @param stateRoot - Root directory containing .pi/
716
+ * @param batchId - Current batch ID
717
+ * @param opts - Message content and metadata
718
+ * @returns The written MailboxMessage
719
+ *
720
+ * @since TP-106
721
+ */
722
+ export function writeBroadcastMessage(
723
+ stateRoot: string,
724
+ batchId: string,
725
+ opts: WriteMailboxMessageOpts,
726
+ ): MailboxMessage {
727
+ return writeMailboxMessage(stateRoot, batchId, "_broadcast", {
728
+ ...opts,
729
+ from: opts.from || "supervisor",
730
+ });
731
+ }
732
+
733
+ // ── Rate Limiting (TP-106) ─────────────────────────────────────
734
+
735
+ /** Default rate limit: max 1 message per agent per 30 seconds. */
736
+ export const RATE_LIMIT_WINDOW_MS = 30_000;
737
+
738
+ /** In-memory rate limit tracker. Keyed by target agent ID. */
739
+ const rateLimitTracker = new Map<string, number>();
740
+
741
+ /**
742
+ * Check whether sending a message to a target is rate-limited.
743
+ *
744
+ * @param targetAgentId - Agent ID being sent to
745
+ * @param windowMs - Rate limit window in ms (default: 30_000)
746
+ * @returns Object with `allowed` and optional `retryAfterMs`
747
+ *
748
+ * @since TP-106
749
+ */
750
+ export function checkRateLimit(
751
+ targetAgentId: string,
752
+ windowMs: number = RATE_LIMIT_WINDOW_MS,
753
+ ): { allowed: boolean; retryAfterMs?: number } {
754
+ const lastSent = rateLimitTracker.get(targetAgentId);
755
+ if (!lastSent) return { allowed: true };
756
+
757
+ const elapsed = Date.now() - lastSent;
758
+ if (elapsed >= windowMs) return { allowed: true };
759
+
760
+ return { allowed: false, retryAfterMs: windowMs - elapsed };
761
+ }
762
+
763
+ /**
764
+ * Record a send timestamp for rate limiting.
765
+ *
766
+ * @param targetAgentId - Agent ID that was sent to
767
+ *
768
+ * @since TP-106
769
+ */
770
+ export function recordSend(targetAgentId: string): void {
771
+ rateLimitTracker.set(targetAgentId, Date.now());
772
+ }
773
+
774
+ /**
775
+ * Reset rate limit state (for testing).
776
+ * @since TP-106
777
+ */
778
+ export function _resetRateLimits(): void {
779
+ rateLimitTracker.clear();
780
+ }