@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.
- package/CHANGELOG.md +41 -0
- package/LICENSE +21 -0
- package/README.md +246 -0
- package/agents/AGENTS-MANIFEST.md +42 -0
- package/agents/brain.md +42 -0
- package/agents/context-builder.md +46 -0
- package/agents/delegate.md +12 -0
- package/agents/dev-1.md +42 -0
- package/agents/oracle.md +73 -0
- package/agents/planner.md +55 -0
- package/agents/researcher.md +52 -0
- package/agents/reviewer.md +79 -0
- package/agents/scout.md +50 -0
- package/agents/tester.md +45 -0
- package/agents/worker.md +55 -0
- package/extensions/ralph.ts +1 -0
- package/extensions/reviewer-extension.ts +125 -0
- package/extensions/task-orchestrator.ts +28 -0
- package/package.json +63 -0
- package/prompts/gather-context-and-clarify.md +13 -0
- package/prompts/parallel-cleanup.md +59 -0
- package/prompts/parallel-context-build.md +53 -0
- package/prompts/parallel-handoff-plan.md +59 -0
- package/prompts/parallel-research.md +50 -0
- package/prompts/parallel-review.md +54 -0
- package/prompts/review-loop.md +41 -0
- package/skills/orchid/SKILL.md +214 -0
- package/skills/orchid/orchid-cleanup/SKILL.md +122 -0
- package/skills/orchid/orchid-converge/SKILL.md +124 -0
- package/skills/orchid/orchid-decompose/SKILL.md +201 -0
- package/skills/orchid/orchid-doctor/SKILL.md +162 -0
- package/skills/orchid/orchid-investigate/SKILL.md +102 -0
- package/skills/orchid/orchid-launch/SKILL.md +147 -0
- package/skills/ralph/SKILL.md +73 -0
- package/skills/subagents/pi-subagents/SKILL.md +813 -0
- package/src/index.ts +7 -0
- package/src/orchestrator/abort.ts +534 -0
- package/src/orchestrator/agent-bridge-extension.ts +1020 -0
- package/src/orchestrator/agent-host.ts +954 -0
- package/src/orchestrator/cleanup.ts +776 -0
- package/src/orchestrator/config-loader.ts +1412 -0
- package/src/orchestrator/config-schema.ts +690 -0
- package/src/orchestrator/config.ts +81 -0
- package/src/orchestrator/context-window.ts +66 -0
- package/src/orchestrator/diagnostic-reports.ts +475 -0
- package/src/orchestrator/diagnostics.ts +394 -0
- package/src/orchestrator/discovery.ts +1833 -0
- package/src/orchestrator/engine-worker.ts +415 -0
- package/src/orchestrator/engine.ts +5940 -0
- package/src/orchestrator/execution.ts +3104 -0
- package/src/orchestrator/extension.ts +5934 -0
- package/src/orchestrator/formatting.ts +785 -0
- package/src/orchestrator/git.ts +88 -0
- package/src/orchestrator/index.ts +28 -0
- package/src/orchestrator/lane-runner.ts +1787 -0
- package/src/orchestrator/mailbox.ts +780 -0
- package/src/orchestrator/merge.ts +3414 -0
- package/src/orchestrator/messages.ts +1062 -0
- package/src/orchestrator/migrations.ts +278 -0
- package/src/orchestrator/naming.ts +117 -0
- package/src/orchestrator/path-resolver.ts +275 -0
- package/src/orchestrator/persistence.ts +2625 -0
- package/src/orchestrator/process-registry.ts +452 -0
- package/src/orchestrator/quality-gate.ts +1085 -0
- package/src/orchestrator/resume.ts +3488 -0
- package/src/orchestrator/sessions.ts +57 -0
- package/src/orchestrator/settings-loader.ts +136 -0
- package/src/orchestrator/settings-tui.ts +2208 -0
- package/src/orchestrator/sidecar-telemetry.ts +267 -0
- package/src/orchestrator/supervisor.ts +4548 -0
- package/src/orchestrator/task-executor-core.ts +675 -0
- package/src/orchestrator/tmux-compat.ts +37 -0
- package/src/orchestrator/tool-allowlist-constants.ts +37 -0
- package/src/orchestrator/types.ts +4465 -0
- package/src/orchestrator/verification.ts +547 -0
- package/src/orchestrator/waves.ts +1564 -0
- package/src/orchestrator/workspace.ts +707 -0
- package/src/orchestrator/worktree.ts +2725 -0
- package/src/ralph/index.ts +825 -0
- package/src/subagents/agents/agent-management.ts +648 -0
- package/src/subagents/agents/agent-scope.ts +6 -0
- package/src/subagents/agents/agent-selection.ts +23 -0
- package/src/subagents/agents/agent-serializer.ts +86 -0
- package/src/subagents/agents/agents.ts +832 -0
- package/src/subagents/agents/chain-serializer.ts +137 -0
- package/src/subagents/agents/frontmatter.ts +29 -0
- package/src/subagents/agents/identity.ts +30 -0
- package/src/subagents/agents/skills.ts +632 -0
- package/src/subagents/extension/config.ts +16 -0
- package/src/subagents/extension/control-notices.ts +92 -0
- package/src/subagents/extension/doctor.ts +199 -0
- package/src/subagents/extension/fanout-child.ts +170 -0
- package/src/subagents/extension/index.ts +573 -0
- package/src/subagents/extension/schemas.ts +168 -0
- package/src/subagents/intercom/intercom-bridge.ts +379 -0
- package/src/subagents/intercom/result-intercom.ts +377 -0
- package/src/subagents/runs/background/async-execution.ts +712 -0
- package/src/subagents/runs/background/async-job-tracker.ts +310 -0
- package/src/subagents/runs/background/async-resume.ts +345 -0
- package/src/subagents/runs/background/async-status.ts +325 -0
- package/src/subagents/runs/background/completion-dedupe.ts +63 -0
- package/src/subagents/runs/background/notify.ts +108 -0
- package/src/subagents/runs/background/parallel-groups.ts +45 -0
- package/src/subagents/runs/background/result-watcher.ts +307 -0
- package/src/subagents/runs/background/run-id-resolver.ts +83 -0
- package/src/subagents/runs/background/run-status.ts +269 -0
- package/src/subagents/runs/background/stale-run-reconciler.ts +336 -0
- package/src/subagents/runs/background/subagent-runner.ts +1808 -0
- package/src/subagents/runs/background/top-level-async.ts +13 -0
- package/src/subagents/runs/foreground/chain-clarify.ts +1333 -0
- package/src/subagents/runs/foreground/chain-execution.ts +938 -0
- package/src/subagents/runs/foreground/execution.ts +918 -0
- package/src/subagents/runs/foreground/subagent-executor.ts +2527 -0
- package/src/subagents/runs/shared/completion-guard.ts +147 -0
- package/src/subagents/runs/shared/long-running-guard.ts +175 -0
- package/src/subagents/runs/shared/mcp-direct-tool-allowlist.ts +365 -0
- package/src/subagents/runs/shared/model-fallback.ts +103 -0
- package/src/subagents/runs/shared/nested-events.ts +819 -0
- package/src/subagents/runs/shared/nested-path.ts +52 -0
- package/src/subagents/runs/shared/nested-render.ts +115 -0
- package/src/subagents/runs/shared/parallel-utils.ts +109 -0
- package/src/subagents/runs/shared/pi-args.ts +220 -0
- package/src/subagents/runs/shared/pi-spawn.ts +115 -0
- package/src/subagents/runs/shared/run-history.ts +60 -0
- package/src/subagents/runs/shared/single-output.ts +164 -0
- package/src/subagents/runs/shared/subagent-control.ts +226 -0
- package/src/subagents/runs/shared/subagent-prompt-runtime.ts +170 -0
- package/src/subagents/runs/shared/worktree.ts +577 -0
- package/src/subagents/shared/artifacts.ts +98 -0
- package/src/subagents/shared/atomic-json.ts +16 -0
- package/src/subagents/shared/file-coalescer.ts +40 -0
- package/src/subagents/shared/fork-context.ts +76 -0
- package/src/subagents/shared/formatters.ts +133 -0
- package/src/subagents/shared/jsonl-writer.ts +81 -0
- package/src/subagents/shared/model-info.ts +78 -0
- package/src/subagents/shared/post-exit-stdio-guard.ts +85 -0
- package/src/subagents/shared/session-identity.ts +10 -0
- package/src/subagents/shared/session-tokens.ts +44 -0
- package/src/subagents/shared/settings.ts +397 -0
- package/src/subagents/shared/status-format.ts +49 -0
- package/src/subagents/shared/types.ts +822 -0
- package/src/subagents/shared/utils.ts +450 -0
- package/src/subagents/slash/prompt-template-bridge.ts +397 -0
- package/src/subagents/slash/slash-bridge.ts +174 -0
- package/src/subagents/slash/slash-commands.ts +528 -0
- package/src/subagents/slash/slash-live-state.ts +292 -0
- package/src/subagents/tui/render-helpers.ts +80 -0
- package/src/subagents/tui/render.ts +1358 -0
- package/templates/agents/local/supervisor.md +33 -0
- package/templates/agents/local/task-merger.md +27 -0
- package/templates/agents/local/task-reviewer.md +30 -0
- package/templates/agents/local/task-worker.md +34 -0
- package/templates/agents/supervisor-routing.md +92 -0
- package/templates/agents/supervisor.md +229 -0
- package/templates/agents/task-merger.md +214 -0
- package/templates/agents/task-reviewer.md +260 -0
- package/templates/agents/task-worker-segment.md +44 -0
- package/templates/agents/task-worker.md +557 -0
- package/templates/tasks/CONTEXT.md +30 -0
- package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -0
- package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -0
- package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -0
- 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
|
+
}
|