@katyella/legio 0.1.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 (219) hide show
  1. package/CHANGELOG.md +422 -0
  2. package/LICENSE +21 -0
  3. package/README.md +555 -0
  4. package/agents/builder.md +141 -0
  5. package/agents/coordinator.md +351 -0
  6. package/agents/cto.md +196 -0
  7. package/agents/gateway.md +276 -0
  8. package/agents/lead.md +281 -0
  9. package/agents/merger.md +156 -0
  10. package/agents/monitor.md +212 -0
  11. package/agents/reviewer.md +142 -0
  12. package/agents/scout.md +131 -0
  13. package/agents/supervisor.md +416 -0
  14. package/bin/legio.mjs +38 -0
  15. package/package.json +77 -0
  16. package/src/agents/checkpoint.test.ts +88 -0
  17. package/src/agents/checkpoint.ts +102 -0
  18. package/src/agents/hooks-deployer.test.ts +1820 -0
  19. package/src/agents/hooks-deployer.ts +574 -0
  20. package/src/agents/identity.test.ts +614 -0
  21. package/src/agents/identity.ts +385 -0
  22. package/src/agents/lifecycle.test.ts +202 -0
  23. package/src/agents/lifecycle.ts +184 -0
  24. package/src/agents/manifest.test.ts +558 -0
  25. package/src/agents/manifest.ts +297 -0
  26. package/src/agents/overlay.test.ts +592 -0
  27. package/src/agents/overlay.ts +316 -0
  28. package/src/beads/client.test.ts +210 -0
  29. package/src/beads/client.ts +227 -0
  30. package/src/beads/molecules.test.ts +320 -0
  31. package/src/beads/molecules.ts +209 -0
  32. package/src/commands/agents.test.ts +325 -0
  33. package/src/commands/agents.ts +286 -0
  34. package/src/commands/clean.test.ts +730 -0
  35. package/src/commands/clean.ts +653 -0
  36. package/src/commands/completions.test.ts +346 -0
  37. package/src/commands/completions.ts +950 -0
  38. package/src/commands/coordinator.test.ts +1524 -0
  39. package/src/commands/coordinator.ts +880 -0
  40. package/src/commands/costs.test.ts +1015 -0
  41. package/src/commands/costs.ts +473 -0
  42. package/src/commands/dashboard.test.ts +94 -0
  43. package/src/commands/dashboard.ts +607 -0
  44. package/src/commands/doctor.test.ts +295 -0
  45. package/src/commands/doctor.ts +213 -0
  46. package/src/commands/down.test.ts +308 -0
  47. package/src/commands/down.ts +124 -0
  48. package/src/commands/errors.test.ts +648 -0
  49. package/src/commands/errors.ts +255 -0
  50. package/src/commands/feed.test.ts +579 -0
  51. package/src/commands/feed.ts +368 -0
  52. package/src/commands/gateway.test.ts +698 -0
  53. package/src/commands/gateway.ts +419 -0
  54. package/src/commands/group.test.ts +262 -0
  55. package/src/commands/group.ts +539 -0
  56. package/src/commands/hooks.test.ts +292 -0
  57. package/src/commands/hooks.ts +210 -0
  58. package/src/commands/init.test.ts +211 -0
  59. package/src/commands/init.ts +622 -0
  60. package/src/commands/inspect.test.ts +670 -0
  61. package/src/commands/inspect.ts +455 -0
  62. package/src/commands/log.test.ts +1556 -0
  63. package/src/commands/log.ts +752 -0
  64. package/src/commands/logs.test.ts +379 -0
  65. package/src/commands/logs.ts +544 -0
  66. package/src/commands/mail.test.ts +1726 -0
  67. package/src/commands/mail.ts +926 -0
  68. package/src/commands/merge.test.ts +676 -0
  69. package/src/commands/merge.ts +374 -0
  70. package/src/commands/metrics.test.ts +444 -0
  71. package/src/commands/metrics.ts +150 -0
  72. package/src/commands/monitor.test.ts +151 -0
  73. package/src/commands/monitor.ts +394 -0
  74. package/src/commands/nudge.test.ts +230 -0
  75. package/src/commands/nudge.ts +373 -0
  76. package/src/commands/prime.test.ts +467 -0
  77. package/src/commands/prime.ts +386 -0
  78. package/src/commands/replay.test.ts +742 -0
  79. package/src/commands/replay.ts +367 -0
  80. package/src/commands/run.test.ts +443 -0
  81. package/src/commands/run.ts +365 -0
  82. package/src/commands/server.test.ts +626 -0
  83. package/src/commands/server.ts +298 -0
  84. package/src/commands/sling.test.ts +810 -0
  85. package/src/commands/sling.ts +700 -0
  86. package/src/commands/spec.test.ts +206 -0
  87. package/src/commands/spec.ts +171 -0
  88. package/src/commands/status.test.ts +276 -0
  89. package/src/commands/status.ts +339 -0
  90. package/src/commands/stop.test.ts +357 -0
  91. package/src/commands/stop.ts +119 -0
  92. package/src/commands/supervisor.test.ts +186 -0
  93. package/src/commands/supervisor.ts +544 -0
  94. package/src/commands/trace.test.ts +746 -0
  95. package/src/commands/trace.ts +332 -0
  96. package/src/commands/up.test.ts +597 -0
  97. package/src/commands/up.ts +275 -0
  98. package/src/commands/watch.test.ts +152 -0
  99. package/src/commands/watch.ts +238 -0
  100. package/src/commands/worktree.test.ts +648 -0
  101. package/src/commands/worktree.ts +266 -0
  102. package/src/config.test.ts +496 -0
  103. package/src/config.ts +616 -0
  104. package/src/doctor/agents.test.ts +448 -0
  105. package/src/doctor/agents.ts +396 -0
  106. package/src/doctor/config-check.test.ts +184 -0
  107. package/src/doctor/config-check.ts +185 -0
  108. package/src/doctor/consistency.test.ts +645 -0
  109. package/src/doctor/consistency.ts +294 -0
  110. package/src/doctor/databases.test.ts +284 -0
  111. package/src/doctor/databases.ts +211 -0
  112. package/src/doctor/dependencies.test.ts +150 -0
  113. package/src/doctor/dependencies.ts +179 -0
  114. package/src/doctor/logs.test.ts +244 -0
  115. package/src/doctor/logs.ts +295 -0
  116. package/src/doctor/merge-queue.test.ts +210 -0
  117. package/src/doctor/merge-queue.ts +144 -0
  118. package/src/doctor/structure.test.ts +285 -0
  119. package/src/doctor/structure.ts +195 -0
  120. package/src/doctor/types.ts +37 -0
  121. package/src/doctor/version.test.ts +130 -0
  122. package/src/doctor/version.ts +131 -0
  123. package/src/e2e/chat-flow.test.ts +346 -0
  124. package/src/e2e/init-sling-lifecycle.test.ts +288 -0
  125. package/src/errors.test.ts +21 -0
  126. package/src/errors.ts +246 -0
  127. package/src/events/store.test.ts +660 -0
  128. package/src/events/store.ts +344 -0
  129. package/src/events/tool-filter.test.ts +330 -0
  130. package/src/events/tool-filter.ts +126 -0
  131. package/src/global-setup.ts +14 -0
  132. package/src/index.ts +339 -0
  133. package/src/insights/analyzer.test.ts +466 -0
  134. package/src/insights/analyzer.ts +203 -0
  135. package/src/logging/color.test.ts +118 -0
  136. package/src/logging/color.ts +71 -0
  137. package/src/logging/logger.test.ts +812 -0
  138. package/src/logging/logger.ts +266 -0
  139. package/src/logging/reporter.test.ts +258 -0
  140. package/src/logging/reporter.ts +109 -0
  141. package/src/logging/sanitizer.test.ts +190 -0
  142. package/src/logging/sanitizer.ts +57 -0
  143. package/src/mail/broadcast.test.ts +203 -0
  144. package/src/mail/broadcast.ts +92 -0
  145. package/src/mail/client.test.ts +873 -0
  146. package/src/mail/client.ts +236 -0
  147. package/src/mail/store.test.ts +815 -0
  148. package/src/mail/store.ts +402 -0
  149. package/src/merge/queue.test.ts +449 -0
  150. package/src/merge/queue.ts +262 -0
  151. package/src/merge/resolver.test.ts +1453 -0
  152. package/src/merge/resolver.ts +759 -0
  153. package/src/metrics/store.test.ts +1167 -0
  154. package/src/metrics/store.ts +511 -0
  155. package/src/metrics/summary.test.ts +397 -0
  156. package/src/metrics/summary.ts +178 -0
  157. package/src/metrics/transcript.test.ts +643 -0
  158. package/src/metrics/transcript.ts +351 -0
  159. package/src/mulch/client.test.ts +547 -0
  160. package/src/mulch/client.ts +416 -0
  161. package/src/server/audit-store.test.ts +384 -0
  162. package/src/server/audit-store.ts +257 -0
  163. package/src/server/headless.test.ts +180 -0
  164. package/src/server/headless.ts +151 -0
  165. package/src/server/index.test.ts +241 -0
  166. package/src/server/index.ts +317 -0
  167. package/src/server/public/app.js +187 -0
  168. package/src/server/public/apple-touch-icon.png +0 -0
  169. package/src/server/public/components/agent-badge.js +37 -0
  170. package/src/server/public/components/data-table.js +114 -0
  171. package/src/server/public/components/gateway-chat.js +256 -0
  172. package/src/server/public/components/issue-card.js +96 -0
  173. package/src/server/public/components/layout.js +88 -0
  174. package/src/server/public/components/message-bubble.js +120 -0
  175. package/src/server/public/components/stat-card.js +26 -0
  176. package/src/server/public/components/terminal-panel.js +140 -0
  177. package/src/server/public/favicon-16.png +0 -0
  178. package/src/server/public/favicon-32.png +0 -0
  179. package/src/server/public/favicon.ico +0 -0
  180. package/src/server/public/favicon.png +0 -0
  181. package/src/server/public/index.html +64 -0
  182. package/src/server/public/lib/api.js +35 -0
  183. package/src/server/public/lib/markdown.js +8 -0
  184. package/src/server/public/lib/preact-setup.js +8 -0
  185. package/src/server/public/lib/state.js +99 -0
  186. package/src/server/public/lib/utils.js +309 -0
  187. package/src/server/public/lib/ws.js +79 -0
  188. package/src/server/public/views/chat.js +983 -0
  189. package/src/server/public/views/costs.js +692 -0
  190. package/src/server/public/views/dashboard.js +781 -0
  191. package/src/server/public/views/gateway-chat.js +622 -0
  192. package/src/server/public/views/inspect.js +399 -0
  193. package/src/server/public/views/issues.js +470 -0
  194. package/src/server/public/views/setup.js +94 -0
  195. package/src/server/public/views/task-detail.js +422 -0
  196. package/src/server/routes.test.ts +3816 -0
  197. package/src/server/routes.ts +1964 -0
  198. package/src/server/websocket.test.ts +288 -0
  199. package/src/server/websocket.ts +196 -0
  200. package/src/sessions/compat.test.ts +109 -0
  201. package/src/sessions/compat.ts +17 -0
  202. package/src/sessions/store.test.ts +969 -0
  203. package/src/sessions/store.ts +480 -0
  204. package/src/test-helpers.test.ts +97 -0
  205. package/src/test-helpers.ts +143 -0
  206. package/src/types.ts +708 -0
  207. package/src/watchdog/daemon.test.ts +1233 -0
  208. package/src/watchdog/daemon.ts +533 -0
  209. package/src/watchdog/health.test.ts +371 -0
  210. package/src/watchdog/health.ts +248 -0
  211. package/src/watchdog/triage.test.ts +162 -0
  212. package/src/watchdog/triage.ts +193 -0
  213. package/src/worktree/manager.test.ts +444 -0
  214. package/src/worktree/manager.ts +224 -0
  215. package/src/worktree/tmux.test.ts +1238 -0
  216. package/src/worktree/tmux.ts +644 -0
  217. package/templates/CLAUDE.md.tmpl +89 -0
  218. package/templates/hooks.json.tmpl +132 -0
  219. package/templates/overlay.md.tmpl +79 -0
@@ -0,0 +1,926 @@
1
+ /**
2
+ * CLI command: legio mail send/check/list/read/reply
3
+ *
4
+ * Parses CLI args and delegates to the mail client.
5
+ * Supports --inject for hook context injection, --json for machine output,
6
+ * and various filters for listing messages.
7
+ */
8
+
9
+ import { access, readFile, writeFile } from "node:fs/promises";
10
+ import { join } from "node:path";
11
+ import { resolveProjectRoot } from "../config.ts";
12
+ import { MailError, ValidationError } from "../errors.ts";
13
+ import { createEventStore } from "../events/store.ts";
14
+ import { isGroupAddress, resolveGroupAddress } from "../mail/broadcast.ts";
15
+ import { createMailClient } from "../mail/client.ts";
16
+ import { createMailStore } from "../mail/store.ts";
17
+ import { openSessionStore } from "../sessions/compat.ts";
18
+ import type { MailAudience, MailMessage } from "../types.ts";
19
+ import { MAIL_MESSAGE_TYPES } from "../types.ts";
20
+ import { nudgeAgent } from "./nudge.ts";
21
+
22
+ /** Valid audience values for mail messages. */
23
+ const VALID_AUDIENCES = ["human", "agent", "both"] as const;
24
+
25
+ /**
26
+ * Protocol message types that default to audience 'agent'.
27
+ * Semantic types (status, question, result, error) default to 'both'.
28
+ * Named differently from PROTOCOL_TYPES in client.ts to avoid confusion.
29
+ */
30
+ const AGENT_AUDIENCE_TYPES: ReadonlySet<string> = new Set([
31
+ "worker_done",
32
+ "merge_ready",
33
+ "merged",
34
+ "merge_failed",
35
+ "escalation",
36
+ "health_check",
37
+ "dispatch",
38
+ "assign",
39
+ ]);
40
+
41
+ /**
42
+ * Parse a named flag value from an args array.
43
+ * Returns the value after the flag, or undefined if not present.
44
+ */
45
+ function getFlag(args: string[], flag: string): string | undefined {
46
+ const idx = args.indexOf(flag);
47
+ if (idx === -1 || idx + 1 >= args.length) {
48
+ return undefined;
49
+ }
50
+ return args[idx + 1];
51
+ }
52
+
53
+ /** Check if a boolean flag is present in the args. */
54
+ function hasFlag(args: string[], flag: string): boolean {
55
+ return args.includes(flag);
56
+ }
57
+
58
+ /** Boolean flags that do NOT consume the next arg as a value. */
59
+ const BOOLEAN_FLAGS = new Set(["--json", "--inject", "--unread", "--all", "--help", "-h"]);
60
+
61
+ /**
62
+ * Extract positional arguments from an args array, skipping flag-value pairs.
63
+ *
64
+ * Iterates through args, skipping `--flag value` pairs for value-bearing flags
65
+ * and lone boolean flags. Everything else is a positional arg.
66
+ */
67
+ function getPositionalArgs(args: string[]): string[] {
68
+ const positional: string[] = [];
69
+ let i = 0;
70
+ while (i < args.length) {
71
+ const arg = args[i];
72
+ if (arg?.startsWith("-")) {
73
+ // It's a flag. If it's boolean, skip just it; otherwise skip it + its value.
74
+ if (BOOLEAN_FLAGS.has(arg)) {
75
+ i += 1;
76
+ } else {
77
+ i += 2; // skip flag + its value
78
+ }
79
+ } else {
80
+ if (arg !== undefined) {
81
+ positional.push(arg);
82
+ }
83
+ i += 1;
84
+ }
85
+ }
86
+ return positional;
87
+ }
88
+
89
+ /** Format a single message for human-readable output. */
90
+ function formatMessage(msg: MailMessage): string {
91
+ const readMarker = msg.read ? " " : "*";
92
+ const priorityTag = msg.priority !== "normal" ? ` [${msg.priority.toUpperCase()}]` : "";
93
+ const audienceTag = msg.audience !== "agent" ? ` [${msg.audience}]` : "";
94
+ const lines: string[] = [
95
+ `${readMarker} ${msg.id} From: ${msg.from} → To: ${msg.to}${priorityTag}`,
96
+ ` Subject: ${msg.subject} (${msg.type}${audienceTag})`,
97
+ ` ${msg.body}`,
98
+ ];
99
+ if (msg.payload !== null) {
100
+ lines.push(` Payload: ${msg.payload}`);
101
+ }
102
+ lines.push(` ${msg.createdAt}`);
103
+ return lines.join("\n");
104
+ }
105
+
106
+ /**
107
+ * Format messages for injection into agent context (audience-filtered inject path).
108
+ *
109
+ * Duplicates the format from client.ts's formatForInjection, needed because that
110
+ * function is not exported. Used when --audience filtering must only mark matching
111
+ * messages as read (requires direct store access rather than client.checkInject).
112
+ */
113
+ function formatMessagesForInjection(messages: MailMessage[]): string {
114
+ if (messages.length === 0) {
115
+ return "";
116
+ }
117
+ const lines: string[] = [
118
+ `📬 You have ${messages.length} new message${messages.length === 1 ? "" : "s"}:`,
119
+ "",
120
+ ];
121
+ for (const msg of messages) {
122
+ const priorityTag = msg.priority !== "normal" ? ` [${msg.priority.toUpperCase()}]` : "";
123
+ lines.push(`--- From: ${msg.from}${priorityTag} (${msg.type}) ---`);
124
+ lines.push(`Subject: ${msg.subject}`);
125
+ lines.push(msg.body);
126
+ if (msg.payload !== null && AGENT_AUDIENCE_TYPES.has(msg.type)) {
127
+ lines.push(`Payload: ${msg.payload}`);
128
+ }
129
+ lines.push(`[Reply with: legio mail reply ${msg.id} --body "..."]`);
130
+ lines.push("");
131
+ }
132
+ return lines.join("\n");
133
+ }
134
+
135
+ /**
136
+ * Open a mail store connected to the project's mail.db.
137
+ * The cwd must already be resolved to the canonical project root.
138
+ */
139
+ function openStore(cwd: string) {
140
+ const dbPath = join(cwd, ".legio", "mail.db");
141
+ return createMailStore(dbPath);
142
+ }
143
+
144
+ // === Pending Nudge Markers ===
145
+ //
146
+ // Instead of sending tmux keys (which corrupt tool I/O), auto-nudge writes
147
+ // a JSON marker file per agent. The `mail check --inject` flow reads and
148
+ // clears these markers, prepending a priority banner to the injected output.
149
+
150
+ /** Directory where pending nudge markers are stored. */
151
+ function pendingNudgeDir(cwd: string): string {
152
+ return join(cwd, ".legio", "pending-nudges");
153
+ }
154
+
155
+ /**
156
+ * Check if an agent is idle (not actively executing a tool).
157
+ *
158
+ * An agent is considered idle when `.legio/agent-busy/{agentName}` does NOT exist.
159
+ * The busy marker is written by hooks during active tool execution and removed when idle.
160
+ * Idle agents can receive a direct tmux nudge; busy agents only get the pending marker.
161
+ */
162
+ async function isAgentIdle(cwd: string, agentName: string): Promise<boolean> {
163
+ const busyPath = join(cwd, ".legio", "agent-busy", agentName);
164
+ try {
165
+ await access(busyPath);
166
+ return false; // busy marker present — agent is actively working
167
+ } catch {
168
+ return true; // no busy marker — agent is idle
169
+ }
170
+ }
171
+
172
+ /** Shape of a pending nudge marker file. */
173
+ interface PendingNudge {
174
+ from: string;
175
+ reason: string;
176
+ subject: string;
177
+ messageId: string;
178
+ createdAt: string;
179
+ }
180
+
181
+ /**
182
+ * Write a pending nudge marker for an agent.
183
+ *
184
+ * Creates `.legio/pending-nudges/{agent}.json` so that the next
185
+ * `mail check --inject` call surfaces a priority banner for this message.
186
+ * Overwrites any existing marker (only the latest nudge matters).
187
+ */
188
+ async function writePendingNudge(
189
+ cwd: string,
190
+ agentName: string,
191
+ nudge: Omit<PendingNudge, "createdAt">,
192
+ ): Promise<void> {
193
+ const dir = pendingNudgeDir(cwd);
194
+ const { mkdir } = await import("node:fs/promises");
195
+ await mkdir(dir, { recursive: true });
196
+
197
+ const marker: PendingNudge = {
198
+ ...nudge,
199
+ createdAt: new Date().toISOString(),
200
+ };
201
+ const filePath = join(dir, `${agentName}.json`);
202
+ await writeFile(filePath, `${JSON.stringify(marker, null, "\t")}\n`);
203
+ }
204
+
205
+ /**
206
+ * Read and clear pending nudge markers for an agent.
207
+ *
208
+ * Returns the pending nudge (if any) and removes the marker file.
209
+ * Called by `mail check --inject` to prepend a priority banner.
210
+ */
211
+ async function readAndClearPendingNudge(
212
+ cwd: string,
213
+ agentName: string,
214
+ ): Promise<PendingNudge | null> {
215
+ const filePath = join(pendingNudgeDir(cwd), `${agentName}.json`);
216
+ try {
217
+ await access(filePath);
218
+ } catch {
219
+ return null;
220
+ }
221
+ try {
222
+ const text = await readFile(filePath, "utf-8");
223
+ const nudge = JSON.parse(text) as PendingNudge;
224
+ const { unlink } = await import("node:fs/promises");
225
+ await unlink(filePath);
226
+ return nudge;
227
+ } catch {
228
+ // Corrupt or race condition — clear it and move on
229
+ try {
230
+ const { unlink } = await import("node:fs/promises");
231
+ await unlink(filePath);
232
+ } catch {
233
+ // Already gone
234
+ }
235
+ return null;
236
+ }
237
+ }
238
+
239
+ // === Mail Check Debounce ===
240
+ //
241
+ // Prevents excessive mail checking by tracking the last check timestamp per agent.
242
+ // When --debounce flag is provided, mail check will skip if called within the
243
+ // debounce window.
244
+
245
+ /**
246
+ * Path to the mail check debounce state file.
247
+ */
248
+ function mailCheckStatePath(cwd: string): string {
249
+ return join(cwd, ".legio", "mail-check-state.json");
250
+ }
251
+
252
+ /**
253
+ * Check if a mail check for this agent is within the debounce window.
254
+ *
255
+ * @param cwd - Project root directory
256
+ * @param agentName - Agent name
257
+ * @param debounceMs - Debounce interval in milliseconds
258
+ * @returns true if the last check was within the debounce window
259
+ */
260
+ async function isMailCheckDebounced(
261
+ cwd: string,
262
+ agentName: string,
263
+ debounceMs: number,
264
+ ): Promise<boolean> {
265
+ const statePath = mailCheckStatePath(cwd);
266
+ try {
267
+ await access(statePath);
268
+ } catch {
269
+ return false;
270
+ }
271
+ try {
272
+ const text = await readFile(statePath, "utf-8");
273
+ const state = JSON.parse(text) as Record<string, number>;
274
+ const lastCheck = state[agentName];
275
+ if (lastCheck === undefined) {
276
+ return false;
277
+ }
278
+ return Date.now() - lastCheck < debounceMs;
279
+ } catch {
280
+ return false;
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Record a mail check timestamp for debounce tracking.
286
+ *
287
+ * @param cwd - Project root directory
288
+ * @param agentName - Agent name
289
+ */
290
+ async function recordMailCheck(cwd: string, agentName: string): Promise<void> {
291
+ const statePath = mailCheckStatePath(cwd);
292
+ let state: Record<string, number> = {};
293
+ try {
294
+ const text = await readFile(statePath, "utf-8");
295
+ state = JSON.parse(text) as Record<string, number>;
296
+ } catch {
297
+ // File does not exist or corrupt state — start fresh
298
+ }
299
+ state[agentName] = Date.now();
300
+ await writeFile(statePath, `${JSON.stringify(state, null, "\t")}\n`);
301
+ }
302
+
303
+ /**
304
+ * Open a mail client connected to the project's mail.db.
305
+ * The cwd must already be resolved to the canonical project root.
306
+ */
307
+ function openClient(cwd: string) {
308
+ const store = openStore(cwd);
309
+ const client = createMailClient(store);
310
+ return client;
311
+ }
312
+
313
+ /** legio mail send */
314
+ async function handleSend(args: string[], cwd: string): Promise<void> {
315
+ const to = getFlag(args, "--to");
316
+ const subject = getFlag(args, "--subject");
317
+ const body = getFlag(args, "--body");
318
+ const from = getFlag(args, "--agent") ?? getFlag(args, "--from") ?? "orchestrator";
319
+ const rawPayload = getFlag(args, "--payload");
320
+ const VALID_PRIORITIES = ["low", "normal", "high", "urgent"] as const;
321
+
322
+ const rawType = getFlag(args, "--type") ?? "status";
323
+ const rawPriority = getFlag(args, "--priority") ?? "normal";
324
+
325
+ if (!MAIL_MESSAGE_TYPES.includes(rawType as MailMessage["type"])) {
326
+ throw new ValidationError(
327
+ `Invalid --type "${rawType}". Must be one of: ${MAIL_MESSAGE_TYPES.join(", ")}`,
328
+ { field: "type", value: rawType },
329
+ );
330
+ }
331
+ if (!VALID_PRIORITIES.includes(rawPriority as MailMessage["priority"])) {
332
+ throw new ValidationError(
333
+ `Invalid --priority "${rawPriority}". Must be one of: ${VALID_PRIORITIES.join(", ")}`,
334
+ { field: "priority", value: rawPriority },
335
+ );
336
+ }
337
+
338
+ const type = rawType as MailMessage["type"];
339
+ const priority = rawPriority as MailMessage["priority"];
340
+
341
+ // Parse --audience flag (optional, auto-derived from type if not specified)
342
+ const rawAudience = getFlag(args, "--audience");
343
+ let audience: MailAudience;
344
+ if (rawAudience !== undefined) {
345
+ if (!(VALID_AUDIENCES as readonly string[]).includes(rawAudience)) {
346
+ throw new ValidationError(
347
+ `Invalid --audience "${rawAudience}". Must be one of: ${VALID_AUDIENCES.join(", ")}`,
348
+ { field: "audience", value: rawAudience },
349
+ );
350
+ }
351
+ audience = rawAudience as MailAudience;
352
+ } else {
353
+ // Auto-derive: protocol types -> "agent", semantic types -> "both"
354
+ audience = AGENT_AUDIENCE_TYPES.has(type) ? "agent" : "both";
355
+ }
356
+
357
+ // Validate JSON payload if provided
358
+ let payload: string | undefined;
359
+ if (rawPayload !== undefined) {
360
+ try {
361
+ JSON.parse(rawPayload);
362
+ payload = rawPayload;
363
+ } catch {
364
+ throw new ValidationError("--payload must be valid JSON", {
365
+ field: "payload",
366
+ value: rawPayload,
367
+ });
368
+ }
369
+ }
370
+
371
+ if (!to) {
372
+ throw new ValidationError("--to is required for mail send", { field: "to" });
373
+ }
374
+ if (!subject) {
375
+ throw new ValidationError("--subject is required for mail send", { field: "subject" });
376
+ }
377
+ if (!body) {
378
+ throw new ValidationError("--body is required for mail send", { field: "body" });
379
+ }
380
+
381
+ // audience field will be added to MailClient.send() by schema-lead (legio-9c89).
382
+ // Cast to pass audience through until the interface is updated.
383
+ type SendWithAudience = (msg: {
384
+ from: string;
385
+ to: string;
386
+ subject: string;
387
+ body: string;
388
+ type?: MailMessage["type"];
389
+ priority?: MailMessage["priority"];
390
+ threadId?: string;
391
+ payload?: string;
392
+ audience?: string;
393
+ }) => string;
394
+
395
+ // Handle broadcast messages (group addresses)
396
+ if (isGroupAddress(to)) {
397
+ const legioDir = join(cwd, ".legio");
398
+ const { store: sessionStore } = openSessionStore(legioDir);
399
+
400
+ try {
401
+ const activeSessions = sessionStore.getActive();
402
+ const recipients = resolveGroupAddress(to, activeSessions, from);
403
+
404
+ const client = openClient(cwd);
405
+ const messageIds: string[] = [];
406
+
407
+ try {
408
+ // Fan out: send individual message to each recipient
409
+ for (const recipient of recipients) {
410
+ const id = (client.send as SendWithAudience)({
411
+ from,
412
+ to: recipient,
413
+ subject,
414
+ body,
415
+ type,
416
+ priority,
417
+ payload,
418
+ audience,
419
+ });
420
+ messageIds.push(id);
421
+
422
+ // Record mail_sent event for each individual message (fire-and-forget)
423
+ try {
424
+ const eventsDbPath = join(cwd, ".legio", "events.db");
425
+ const eventStore = createEventStore(eventsDbPath);
426
+ try {
427
+ let runId: string | null = null;
428
+ const runIdPath = join(cwd, ".legio", "current-run.txt");
429
+ try {
430
+ const text = await readFile(runIdPath, "utf-8");
431
+ const trimmed = text.trim();
432
+ if (trimmed.length > 0) {
433
+ runId = trimmed;
434
+ }
435
+ } catch {
436
+ /* file doesn't exist */
437
+ }
438
+ eventStore.insert({
439
+ runId,
440
+ agentName: from,
441
+ sessionId: null,
442
+ eventType: "mail_sent",
443
+ toolName: null,
444
+ toolArgs: null,
445
+ toolDurationMs: null,
446
+ level: "info",
447
+ data: JSON.stringify({
448
+ to: recipient,
449
+ subject,
450
+ type,
451
+ priority,
452
+ messageId: id,
453
+ broadcast: true,
454
+ }),
455
+ });
456
+ } finally {
457
+ eventStore.close();
458
+ }
459
+ } catch {
460
+ // Event recording failure is non-fatal
461
+ }
462
+
463
+ // Auto-nudge for each individual message (always fire for all types/priorities)
464
+ const nudgeReason = type;
465
+ await writePendingNudge(cwd, recipient, {
466
+ from,
467
+ reason: nudgeReason,
468
+ subject,
469
+ messageId: id,
470
+ });
471
+ // Smart push: if recipient is idle, also deliver direct tmux nudge
472
+ if (await isAgentIdle(cwd, recipient)) {
473
+ await nudgeAgent(
474
+ cwd,
475
+ recipient,
476
+ `[mail from ${from}] ${nudgeReason}: ${subject}`,
477
+ true,
478
+ ).catch(() => {
479
+ /* non-fatal: pending marker is the reliable path */
480
+ });
481
+ }
482
+ }
483
+ } finally {
484
+ client.close();
485
+ }
486
+
487
+ // Output broadcast summary
488
+ if (hasFlag(args, "--json")) {
489
+ process.stdout.write(
490
+ `${JSON.stringify({ messageIds, recipientCount: recipients.length })}\n`,
491
+ );
492
+ } else {
493
+ process.stdout.write(
494
+ `📢 Broadcast sent to ${recipients.length} recipient${recipients.length === 1 ? "" : "s"} (${to})\n`,
495
+ );
496
+ for (let i = 0; i < recipients.length; i++) {
497
+ const recipient = recipients[i];
498
+ const msgId = messageIds[i];
499
+ process.stdout.write(` → ${recipient} (${msgId})\n`);
500
+ }
501
+ }
502
+
503
+ return; // Early return — broadcast handled
504
+ } finally {
505
+ sessionStore.close();
506
+ }
507
+ }
508
+
509
+ // Single-recipient message (existing logic)
510
+ const client = openClient(cwd);
511
+ try {
512
+ const id = (client.send as SendWithAudience)({
513
+ from,
514
+ to,
515
+ subject,
516
+ body,
517
+ type,
518
+ priority,
519
+ payload,
520
+ audience,
521
+ });
522
+
523
+ // Record mail_sent event to EventStore (fire-and-forget)
524
+ try {
525
+ const eventsDbPath = join(cwd, ".legio", "events.db");
526
+ const eventStore = createEventStore(eventsDbPath);
527
+ try {
528
+ let runId: string | null = null;
529
+ const runIdPath = join(cwd, ".legio", "current-run.txt");
530
+ try {
531
+ const text = await readFile(runIdPath, "utf-8");
532
+ const trimmed = text.trim();
533
+ if (trimmed.length > 0) {
534
+ runId = trimmed;
535
+ }
536
+ } catch {
537
+ /* file doesn't exist */
538
+ }
539
+ eventStore.insert({
540
+ runId,
541
+ agentName: from,
542
+ sessionId: null,
543
+ eventType: "mail_sent",
544
+ toolName: null,
545
+ toolArgs: null,
546
+ toolDurationMs: null,
547
+ level: "info",
548
+ data: JSON.stringify({ to, subject, type, priority, messageId: id }),
549
+ });
550
+ } finally {
551
+ eventStore.close();
552
+ }
553
+ } catch {
554
+ // Event recording failure is non-fatal
555
+ }
556
+
557
+ if (hasFlag(args, "--json")) {
558
+ process.stdout.write(`${JSON.stringify({ id })}\n`);
559
+ } else {
560
+ process.stdout.write(`✉️ Sent message ${id} to ${to}\n`);
561
+ }
562
+
563
+ // Auto-nudge: write a pending nudge marker instead of sending tmux keys.
564
+ // Direct tmux sendKeys during tool execution corrupts the agent's I/O,
565
+ // causing SIGKILL (exit 137) and "request interrupted" errors (legio-ii1o).
566
+ // The message is already in the DB — the UserPromptSubmit hook's
567
+ // `mail check --inject` will surface it on the next prompt cycle.
568
+ // Auto-nudge fires for ALL message types and priorities — no type/priority gate.
569
+ // The nudge mechanism has debounce protection to prevent rapid-fire nudges.
570
+ const nudgeReason = type;
571
+ await writePendingNudge(cwd, to, {
572
+ from,
573
+ reason: nudgeReason,
574
+ subject,
575
+ messageId: id,
576
+ });
577
+ // Smart push: if recipient is idle, also deliver direct tmux nudge
578
+ if (await isAgentIdle(cwd, to)) {
579
+ await nudgeAgent(cwd, to, `[mail from ${from}] ${nudgeReason}: ${subject}`, true).catch(
580
+ () => {
581
+ /* non-fatal: pending marker is the reliable path */
582
+ },
583
+ );
584
+ }
585
+ if (!hasFlag(args, "--json")) {
586
+ process.stdout.write(
587
+ `📢 Queued nudge for "${to}" (${nudgeReason}, delivered on next prompt)\n`,
588
+ );
589
+ }
590
+ // Reviewer coverage check for merge_ready (advisory warning)
591
+ if (type === "merge_ready") {
592
+ try {
593
+ const legioDir = join(cwd, ".legio");
594
+ const { store: sessionStore } = openSessionStore(legioDir);
595
+ try {
596
+ const allSessions = sessionStore.getAll();
597
+ const myBuilders = allSessions.filter(
598
+ (s) => s.parentAgent === from && s.capability === "builder",
599
+ );
600
+ const myReviewers = allSessions.filter(
601
+ (s) => s.parentAgent === from && s.capability === "reviewer",
602
+ );
603
+ if (myBuilders.length > 0 && myReviewers.length === 0) {
604
+ process.stderr.write(
605
+ `\n⚠️ WARNING: merge_ready sent but NO reviewer sessions found for "${from}".\n` +
606
+ `⚠️ ${myBuilders.length} builder(s) completed without review. This violates the review-before-merge requirement.\n` +
607
+ `⚠️ Spawn reviewers for each builder before merge. See REVIEW_SKIP in agents/lead.md.\n\n`,
608
+ );
609
+ } else if (myReviewers.length > 0 && myReviewers.length < myBuilders.length) {
610
+ process.stderr.write(
611
+ `\n⚠️ NOTE: Only ${myReviewers.length} reviewer(s) for ${myBuilders.length} builder(s). Ensure all builder work is review-verified.\n\n`,
612
+ );
613
+ }
614
+ } finally {
615
+ sessionStore.close();
616
+ }
617
+ } catch {
618
+ // Reviewer check failure is non-fatal — do not block mail send
619
+ }
620
+ }
621
+ } finally {
622
+ client.close();
623
+ }
624
+ }
625
+
626
+ /** legio mail check */
627
+ async function handleCheck(args: string[], cwd: string): Promise<void> {
628
+ const agent = getFlag(args, "--agent") ?? "orchestrator";
629
+ const inject = hasFlag(args, "--inject");
630
+ const json = hasFlag(args, "--json");
631
+ const debounceFlag = getFlag(args, "--debounce");
632
+ const audience = getFlag(args, "--audience");
633
+ if (audience !== undefined && !(VALID_AUDIENCES as readonly string[]).includes(audience)) {
634
+ throw new ValidationError(
635
+ `Invalid --audience "${audience}". Must be one of: ${VALID_AUDIENCES.join(", ")}`,
636
+ { field: "audience", value: audience },
637
+ );
638
+ }
639
+
640
+ // Parse debounce interval if provided
641
+ let debounceMs: number | undefined;
642
+ if (debounceFlag !== undefined) {
643
+ const parsed = Number.parseInt(debounceFlag, 10);
644
+ if (Number.isNaN(parsed) || parsed < 0) {
645
+ throw new ValidationError(
646
+ `--debounce must be a non-negative integer (milliseconds), got: ${debounceFlag}`,
647
+ { field: "debounce", value: debounceFlag },
648
+ );
649
+ }
650
+ debounceMs = parsed;
651
+ }
652
+
653
+ // Check debounce if enabled
654
+ if (debounceMs !== undefined) {
655
+ const debounced = await isMailCheckDebounced(cwd, agent, debounceMs);
656
+ if (debounced) {
657
+ // Silent skip — no output when debounced
658
+ return;
659
+ }
660
+ }
661
+
662
+ const client = openClient(cwd);
663
+ try {
664
+ if (inject) {
665
+ // Check for pending nudge markers (written by auto-nudge instead of tmux keys)
666
+ const pendingNudge = await readAndClearPendingNudge(cwd, agent);
667
+ let injectOutput: string;
668
+
669
+ if (audience !== undefined) {
670
+ // Audience-filtered inject: use store directly to mark only filtered messages as read.
671
+ // This prevents silently consuming messages intended for a different audience.
672
+ const store = openStore(cwd);
673
+ try {
674
+ const allUnread = store.getUnread(agent);
675
+ const filtered = allUnread.filter((m) => m.audience === audience);
676
+ for (const msg of filtered) {
677
+ store.markRead(msg.id);
678
+ }
679
+ injectOutput = formatMessagesForInjection(filtered);
680
+ } finally {
681
+ store.close();
682
+ }
683
+ } else {
684
+ injectOutput = client.checkInject(agent);
685
+ }
686
+
687
+ // Prepend a priority banner if there's a pending nudge
688
+ if (pendingNudge) {
689
+ const banner = `🚨 PRIORITY: ${pendingNudge.reason} message from ${pendingNudge.from} — "${pendingNudge.subject}"\n\n`;
690
+ process.stdout.write(banner);
691
+ }
692
+
693
+ if (injectOutput.length > 0) {
694
+ process.stdout.write(injectOutput);
695
+ }
696
+ } else {
697
+ let messages = client.check(agent);
698
+ if (audience !== undefined) {
699
+ messages = messages.filter((m) => m.audience === audience);
700
+ }
701
+
702
+ if (json) {
703
+ process.stdout.write(`${JSON.stringify(messages)}\n`);
704
+ } else if (messages.length === 0) {
705
+ process.stdout.write("No new messages.\n");
706
+ } else {
707
+ process.stdout.write(
708
+ `📬 ${messages.length} new message${messages.length === 1 ? "" : "s"}:\n\n`,
709
+ );
710
+ for (const msg of messages) {
711
+ process.stdout.write(`${formatMessage(msg)}\n\n`);
712
+ }
713
+ }
714
+ }
715
+
716
+ // Record this check for debounce tracking (only if debounce is enabled)
717
+ if (debounceMs !== undefined) {
718
+ await recordMailCheck(cwd, agent);
719
+ }
720
+ } finally {
721
+ client.close();
722
+ }
723
+ }
724
+
725
+ /** legio mail list */
726
+ function handleList(args: string[], cwd: string): void {
727
+ const from = getFlag(args, "--from");
728
+ // --agent is an alias for --to, providing agent-scoped perspective (like mail check)
729
+ const to = getFlag(args, "--to") ?? getFlag(args, "--agent");
730
+ const unread = hasFlag(args, "--unread") ? true : undefined;
731
+ const json = hasFlag(args, "--json");
732
+ const audience = getFlag(args, "--audience");
733
+ if (audience !== undefined && !(VALID_AUDIENCES as readonly string[]).includes(audience)) {
734
+ throw new ValidationError(
735
+ `Invalid --audience "${audience}". Must be one of: ${VALID_AUDIENCES.join(", ")}`,
736
+ { field: "audience", value: audience },
737
+ );
738
+ }
739
+
740
+ const client = openClient(cwd);
741
+ try {
742
+ const messages = client.list({ from, to, unread, audience });
743
+
744
+ if (json) {
745
+ process.stdout.write(`${JSON.stringify(messages)}\n`);
746
+ } else if (messages.length === 0) {
747
+ process.stdout.write("No messages found.\n");
748
+ } else {
749
+ for (const msg of messages) {
750
+ process.stdout.write(`${formatMessage(msg)}\n\n`);
751
+ }
752
+ process.stdout.write(
753
+ `Total: ${messages.length} message${messages.length === 1 ? "" : "s"}\n`,
754
+ );
755
+ }
756
+ } finally {
757
+ client.close();
758
+ }
759
+ }
760
+
761
+ /** legio mail read */
762
+ function handleRead(args: string[], cwd: string): void {
763
+ const positional = getPositionalArgs(args);
764
+ const id = positional[0];
765
+ if (!id) {
766
+ throw new ValidationError("Message ID is required for mail read", { field: "id" });
767
+ }
768
+
769
+ const client = openClient(cwd);
770
+ try {
771
+ const { alreadyRead } = client.markRead(id);
772
+ if (alreadyRead) {
773
+ process.stdout.write(`Message ${id} was already read.\n`);
774
+ } else {
775
+ process.stdout.write(`Marked ${id} as read.\n`);
776
+ }
777
+ } finally {
778
+ client.close();
779
+ }
780
+ }
781
+
782
+ /** legio mail reply */
783
+ function handleReply(args: string[], cwd: string): void {
784
+ const positional = getPositionalArgs(args);
785
+ const id = positional[0];
786
+ const body = getFlag(args, "--body");
787
+ const from = getFlag(args, "--agent") ?? getFlag(args, "--from") ?? "orchestrator";
788
+
789
+ if (!id) {
790
+ throw new ValidationError("Message ID is required for mail reply", { field: "id" });
791
+ }
792
+ if (!body) {
793
+ throw new ValidationError("--body is required for mail reply", { field: "body" });
794
+ }
795
+
796
+ const client = openClient(cwd);
797
+ try {
798
+ const replyId = client.reply(id, body, from);
799
+
800
+ if (hasFlag(args, "--json")) {
801
+ process.stdout.write(`${JSON.stringify({ id: replyId })}\n`);
802
+ } else {
803
+ process.stdout.write(`✉️ Reply sent: ${replyId}\n`);
804
+ }
805
+ } finally {
806
+ client.close();
807
+ }
808
+ }
809
+
810
+ /** legio mail purge */
811
+ function handlePurge(args: string[], cwd: string): void {
812
+ const all = hasFlag(args, "--all");
813
+ const daysStr = getFlag(args, "--days");
814
+ const agent = getFlag(args, "--agent");
815
+ const json = hasFlag(args, "--json");
816
+
817
+ if (!all && daysStr === undefined && agent === undefined) {
818
+ throw new ValidationError(
819
+ "mail purge requires at least one filter: --all, --days <n>, or --agent <name>",
820
+ { field: "purge" },
821
+ );
822
+ }
823
+
824
+ let olderThanMs: number | undefined;
825
+ if (daysStr !== undefined) {
826
+ const days = Number.parseInt(daysStr, 10);
827
+ if (Number.isNaN(days) || days <= 0) {
828
+ throw new ValidationError("--days must be a positive integer", {
829
+ field: "days",
830
+ value: daysStr,
831
+ });
832
+ }
833
+ olderThanMs = days * 24 * 60 * 60 * 1000;
834
+ }
835
+
836
+ const store = openStore(cwd);
837
+ try {
838
+ const purged = store.purge({ all, olderThanMs, agent });
839
+
840
+ if (json) {
841
+ process.stdout.write(`${JSON.stringify({ purged })}\n`);
842
+ } else {
843
+ process.stdout.write(`Purged ${purged} message${purged === 1 ? "" : "s"}.\n`);
844
+ }
845
+ } finally {
846
+ store.close();
847
+ }
848
+ }
849
+
850
+ /**
851
+ * Entry point for `legio mail <subcommand> [args...]`.
852
+ *
853
+ * Subcommands: send, check, list, read, reply, purge.
854
+ */
855
+ const MAIL_HELP = `legio mail — Agent messaging system
856
+
857
+ Usage: legio mail <subcommand> [args...]
858
+
859
+ Subcommands:
860
+ send Send a message
861
+ --to <agent> --subject <text> --body <text>
862
+ [--from <name>] [--agent <name> (alias for --from)]
863
+ [--type <type>] [--priority <low|normal|high|urgent>]
864
+ [--audience <human|agent|both>]
865
+ [--payload <json>] [--json]
866
+ Types: status, question, result, error (semantic)
867
+ worker_done, merge_ready, merged, merge_failed,
868
+ escalation, health_check, dispatch, assign (protocol)
869
+ Audience: defaults to 'agent' for protocol types, 'both' for semantic types
870
+ check Check inbox (unread messages)
871
+ [--agent <name>] [--audience <human|agent|both>]
872
+ [--inject] [--json]
873
+ list List messages with filters
874
+ [--from <name>] [--to <name>] [--agent <name> (alias for --to)]
875
+ [--audience <human|agent|both>] [--unread] [--json]
876
+ read Mark a message as read
877
+ <message-id>
878
+ reply Reply to a message
879
+ <message-id> --body <text> [--from <name>]
880
+ [--agent <name> (alias for --from)] [--json]
881
+ purge Delete old messages
882
+ --all | --days <n> | --agent <name>
883
+ [--json]
884
+
885
+ Options:
886
+ --help, -h Show this help`;
887
+
888
+ export async function mailCommand(args: string[]): Promise<void> {
889
+ if (args.includes("--help") || args.includes("-h")) {
890
+ process.stdout.write(`${MAIL_HELP}\n`);
891
+ return;
892
+ }
893
+
894
+ const subcommand = args[0];
895
+ const subArgs = args.slice(1);
896
+
897
+ // Resolve the actual project root (handles git worktrees).
898
+ // Mail commands may run from agent worktrees via hooks, so we must
899
+ // resolve up to the main project root where .legio/mail.db lives.
900
+ const root = await resolveProjectRoot(process.cwd());
901
+
902
+ switch (subcommand) {
903
+ case "send":
904
+ await handleSend(subArgs, root);
905
+ break;
906
+ case "check":
907
+ await handleCheck(subArgs, root);
908
+ break;
909
+ case "list":
910
+ handleList(subArgs, root);
911
+ break;
912
+ case "read":
913
+ handleRead(subArgs, root);
914
+ break;
915
+ case "reply":
916
+ handleReply(subArgs, root);
917
+ break;
918
+ case "purge":
919
+ handlePurge(subArgs, root);
920
+ break;
921
+ default:
922
+ throw new MailError(
923
+ `Unknown mail subcommand: ${subcommand ?? "(none)"}. Use: send, check, list, read, reply, purge`,
924
+ );
925
+ }
926
+ }