@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,880 @@
1
+ /**
2
+ * CLI command: legio coordinator start|stop|status
3
+ *
4
+ * Manages the persistent coordinator agent lifecycle. The coordinator runs
5
+ * at the project root (NOT in a worktree), receives work via mail and beads,
6
+ * and dispatches agents via legio sling.
7
+ *
8
+ * Unlike regular agents spawned by sling, the coordinator:
9
+ * - Has no worktree (operates on the main working tree)
10
+ * - Has no bead assignment (it creates beads, not works on them)
11
+ * - Has no overlay CLAUDE.md (context comes via mail + beads + checkpoints)
12
+ * - Persists across work batches
13
+ */
14
+
15
+ import { spawn, spawnSync } from "node:child_process";
16
+ import { access, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
17
+ import { join } from "node:path";
18
+ import { deployHooks } from "../agents/hooks-deployer.ts";
19
+ import { createIdentity, loadIdentity } from "../agents/identity.ts";
20
+ import { createManifestLoader, resolveModel } from "../agents/manifest.ts";
21
+ import { collectProviderEnv, loadConfig } from "../config.ts";
22
+ import { AgentError, isRunningAsRoot, ValidationError } from "../errors.ts";
23
+ import { HeadlessCoordinator } from "../server/headless.ts";
24
+ import { openSessionStore } from "../sessions/compat.ts";
25
+ import { createRunStore } from "../sessions/store.ts";
26
+ import type { AgentSession, HeadlessCoordinatorConfig } from "../types.ts";
27
+ import { isProcessRunning } from "../watchdog/health.ts";
28
+ import {
29
+ createSession,
30
+ isSessionAlive,
31
+ killSession,
32
+ sendKeys,
33
+ waitForTuiReady,
34
+ } from "../worktree/tmux.ts";
35
+
36
+ /** Default coordinator agent name. */
37
+ const COORDINATOR_NAME = "coordinator";
38
+
39
+ /**
40
+ * Build the tmux session name for the coordinator.
41
+ * Includes the project name to prevent cross-project collisions (legio-pcef).
42
+ */
43
+ function coordinatorTmuxSession(projectName: string): string {
44
+ return `legio-${projectName}-${COORDINATOR_NAME}`;
45
+ }
46
+
47
+ /**
48
+ * Run a subprocess and collect its output. Returns exit code, stdout, and stderr.
49
+ */
50
+ function runProcess(
51
+ cmd: string[],
52
+ opts: { cwd?: string } = {},
53
+ ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
54
+ return new Promise((resolve, reject) => {
55
+ const [command, ...args] = cmd;
56
+ if (!command) {
57
+ reject(new Error("Empty command array"));
58
+ return;
59
+ }
60
+ const proc = spawn(command, args, {
61
+ cwd: opts.cwd,
62
+ stdio: ["ignore", "pipe", "pipe"],
63
+ });
64
+ let stdout = "";
65
+ let stderr = "";
66
+ proc.stdout.on("data", (d: Buffer) => {
67
+ stdout += d.toString();
68
+ });
69
+ proc.stderr.on("data", (d: Buffer) => {
70
+ stderr += d.toString();
71
+ });
72
+ proc.on("close", (code) => resolve({ exitCode: code ?? 1, stdout, stderr }));
73
+ proc.on("error", reject);
74
+ });
75
+ }
76
+
77
+ /**
78
+ * Check if a file exists at the given path.
79
+ */
80
+ async function fileExists(path: string): Promise<boolean> {
81
+ try {
82
+ await access(path);
83
+ return true;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ /** Minimal interface required from a HeadlessCoordinator for DI testing. */
90
+ export interface HeadlessCoordinatorHandle {
91
+ start(): void;
92
+ write(input: string): void;
93
+ stop(): Promise<void>;
94
+ getPid(): number | null;
95
+ isRunning(): boolean;
96
+ on(event: "exit", listener: (code: number) => void): this;
97
+ }
98
+
99
+ /** Dependency injection for testing. Uses real implementations when omitted. */
100
+ export interface CoordinatorDeps {
101
+ _tmux?: {
102
+ createSession: (
103
+ name: string,
104
+ cwd: string,
105
+ command: string,
106
+ env?: Record<string, string>,
107
+ ) => Promise<number>;
108
+ isSessionAlive: (name: string) => Promise<boolean>;
109
+ killSession: (name: string) => Promise<void>;
110
+ sendKeys: (name: string, keys: string) => Promise<void>;
111
+ waitForTuiReady?: (
112
+ sessionName: string,
113
+ opts?: { timeout?: number; interval?: number },
114
+ ) => Promise<void>;
115
+ };
116
+ _watchdog?: {
117
+ start: () => Promise<{ pid: number } | null>;
118
+ stop: () => Promise<boolean>;
119
+ isRunning: () => Promise<boolean>;
120
+ };
121
+ _monitor?: {
122
+ start: (args: string[]) => Promise<{ pid: number } | null>;
123
+ stop: () => Promise<boolean>;
124
+ isRunning: () => Promise<boolean>;
125
+ };
126
+ _headless?: {
127
+ create: (config: HeadlessCoordinatorConfig) => HeadlessCoordinatorHandle;
128
+ };
129
+ _sleep?: (ms: number) => Promise<void>;
130
+ }
131
+
132
+ /**
133
+ * Read the PID from the watchdog PID file.
134
+ * Returns null if the file doesn't exist or can't be parsed.
135
+ */
136
+ async function readWatchdogPid(projectRoot: string): Promise<number | null> {
137
+ const pidFilePath = join(projectRoot, ".legio", "watchdog.pid");
138
+ if (!(await fileExists(pidFilePath))) {
139
+ return null;
140
+ }
141
+
142
+ try {
143
+ const text = await readFile(pidFilePath, "utf-8");
144
+ const pid = Number.parseInt(text.trim(), 10);
145
+ if (Number.isNaN(pid) || pid <= 0) {
146
+ return null;
147
+ }
148
+ return pid;
149
+ } catch {
150
+ return null;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Remove the watchdog PID file.
156
+ */
157
+ async function removeWatchdogPid(projectRoot: string): Promise<void> {
158
+ const pidFilePath = join(projectRoot, ".legio", "watchdog.pid");
159
+ try {
160
+ await unlink(pidFilePath);
161
+ } catch {
162
+ // File may already be gone — not an error
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Default watchdog implementation for production use.
168
+ * Starts/stops the watchdog daemon via `legio watch --background`.
169
+ */
170
+ function createDefaultWatchdog(projectRoot: string): NonNullable<CoordinatorDeps["_watchdog"]> {
171
+ return {
172
+ async start(): Promise<{ pid: number } | null> {
173
+ // Check if watchdog is already running
174
+ const existingPid = await readWatchdogPid(projectRoot);
175
+ if (existingPid !== null && isProcessRunning(existingPid)) {
176
+ return null; // Already running
177
+ }
178
+
179
+ // Clean up stale PID file
180
+ if (existingPid !== null) {
181
+ await removeWatchdogPid(projectRoot);
182
+ }
183
+
184
+ // Start watchdog in background
185
+ const { exitCode } = await runProcess(["legio", "watch", "--background"], {
186
+ cwd: projectRoot,
187
+ });
188
+ if (exitCode !== 0) {
189
+ return null; // Failed to start
190
+ }
191
+
192
+ // Read the PID file that was written by the background process
193
+ const pid = await readWatchdogPid(projectRoot);
194
+ if (pid === null) {
195
+ return null; // PID file wasn't created
196
+ }
197
+
198
+ return { pid };
199
+ },
200
+
201
+ async stop(): Promise<boolean> {
202
+ const pid = await readWatchdogPid(projectRoot);
203
+ if (pid === null) {
204
+ return false; // No PID file
205
+ }
206
+
207
+ // Check if process is running
208
+ if (!isProcessRunning(pid)) {
209
+ // Process is dead, clean up PID file
210
+ await removeWatchdogPid(projectRoot);
211
+ return false;
212
+ }
213
+
214
+ // Kill the process
215
+ try {
216
+ process.kill(pid, 15); // SIGTERM
217
+ } catch {
218
+ return false;
219
+ }
220
+
221
+ // Remove PID file
222
+ await removeWatchdogPid(projectRoot);
223
+ return true;
224
+ },
225
+
226
+ async isRunning(): Promise<boolean> {
227
+ const pid = await readWatchdogPid(projectRoot);
228
+ if (pid === null) {
229
+ return false;
230
+ }
231
+ return isProcessRunning(pid);
232
+ },
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Default monitor implementation for production use.
238
+ * Starts/stops the monitor agent via `legio monitor start/stop`.
239
+ */
240
+ function createDefaultMonitor(projectRoot: string): NonNullable<CoordinatorDeps["_monitor"]> {
241
+ return {
242
+ async start(): Promise<{ pid: number } | null> {
243
+ const { exitCode, stdout } = await runProcess(
244
+ ["legio", "monitor", "start", "--no-attach", "--json"],
245
+ { cwd: projectRoot },
246
+ );
247
+ if (exitCode !== 0) return null;
248
+ try {
249
+ const result = JSON.parse(stdout.trim()) as { pid?: number };
250
+ return result.pid ? { pid: result.pid } : null;
251
+ } catch {
252
+ return null;
253
+ }
254
+ },
255
+ async stop(): Promise<boolean> {
256
+ const { exitCode } = await runProcess(["legio", "monitor", "stop", "--json"], {
257
+ cwd: projectRoot,
258
+ });
259
+ return exitCode === 0;
260
+ },
261
+ async isRunning(): Promise<boolean> {
262
+ const { exitCode, stdout } = await runProcess(["legio", "monitor", "status", "--json"], {
263
+ cwd: projectRoot,
264
+ });
265
+ if (exitCode !== 0) return false;
266
+ try {
267
+ const result = JSON.parse(stdout.trim()) as { running?: boolean };
268
+ return result.running === true;
269
+ } catch {
270
+ return false;
271
+ }
272
+ },
273
+ };
274
+ }
275
+
276
+ /**
277
+ * Build the coordinator startup beacon — the first message sent to the coordinator
278
+ * via tmux send-keys after Claude Code initializes.
279
+ */
280
+ export function buildCoordinatorBeacon(): string {
281
+ const timestamp = new Date().toISOString();
282
+ const parts = [
283
+ `[LEGIO] ${COORDINATOR_NAME} (coordinator) ${timestamp}`,
284
+ "Depth: 0 | Parent: none | Role: persistent orchestrator",
285
+ "HIERARCHY: You ONLY spawn leads (legio sling --capability lead). Leads spawn scouts, builders, reviewers. NEVER spawn non-lead agents directly.",
286
+ "DELEGATION: For any exploration/scouting, spawn a lead who will spawn scouts. Do NOT explore the codebase yourself beyond initial planning.",
287
+ `Startup: run mulch prime, check mail (legio mail check --agent ${COORDINATOR_NAME}), check bd ready, check legio group status, then begin work`,
288
+ ];
289
+ return parts.join(" — ");
290
+ }
291
+
292
+ /**
293
+ * Start the coordinator agent.
294
+ *
295
+ * 1. Verify no coordinator is already running
296
+ * 2. Load config
297
+ * 3. Create agent identity (if first time)
298
+ * 4. Deploy hooks to project root's .claude/settings.local.json
299
+ * 5. Spawn tmux session at project root with Claude Code
300
+ * 6. Send startup beacon
301
+ * 7. Record session in SessionStore (sessions.db)
302
+ */
303
+ /**
304
+ * Determine whether to auto-attach to the tmux session after starting.
305
+ * Exported for testing.
306
+ */
307
+ export function resolveAttach(args: string[], isTTY: boolean): boolean {
308
+ if (args.includes("--attach")) return true;
309
+ if (args.includes("--no-attach")) return false;
310
+ return isTTY;
311
+ }
312
+
313
+ async function startCoordinator(args: string[], deps: CoordinatorDeps = {}): Promise<void> {
314
+ const tmux = deps._tmux ?? {
315
+ createSession,
316
+ isSessionAlive,
317
+ killSession,
318
+ sendKeys,
319
+ waitForTuiReady,
320
+ };
321
+ const sleep =
322
+ deps._sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
323
+
324
+ if (isRunningAsRoot()) {
325
+ throw new ValidationError(
326
+ "legio must not run as root — agent processes execute arbitrary code",
327
+ {
328
+ field: "uid",
329
+ },
330
+ );
331
+ }
332
+
333
+ const json = args.includes("--json");
334
+ const shouldAttach = resolveAttach(args, !!process.stdout.isTTY);
335
+ const watchdogFlag = args.includes("--watchdog");
336
+ const monitorFlag = args.includes("--monitor");
337
+ const headlessFlag = args.includes("--headless");
338
+ const cwd = process.cwd();
339
+ const config = await loadConfig(cwd);
340
+ const projectRoot = config.project.root;
341
+ const watchdog = deps._watchdog ?? createDefaultWatchdog(projectRoot);
342
+ const monitor = deps._monitor ?? createDefaultMonitor(projectRoot);
343
+ const tmuxSession = coordinatorTmuxSession(config.project.name);
344
+
345
+ // Check for existing coordinator
346
+ const legioDir = join(projectRoot, ".legio");
347
+ const { store } = openSessionStore(legioDir);
348
+ try {
349
+ const existing = store.getByName(COORDINATOR_NAME);
350
+
351
+ if (
352
+ existing &&
353
+ existing.capability === "coordinator" &&
354
+ existing.state !== "completed" &&
355
+ existing.state !== "zombie"
356
+ ) {
357
+ const alive = await tmux.isSessionAlive(existing.tmuxSession);
358
+ if (alive) {
359
+ throw new AgentError(
360
+ `Coordinator is already running (tmux: ${existing.tmuxSession}, since: ${existing.startedAt})`,
361
+ { agentName: COORDINATOR_NAME },
362
+ );
363
+ }
364
+ // Session recorded but tmux is dead — mark as completed and continue
365
+ store.updateState(COORDINATOR_NAME, "completed");
366
+ }
367
+
368
+ // Deploy hooks to the project root so the coordinator gets event logging,
369
+ // mail check --inject, and activity tracking via the standard hook pipeline.
370
+ // The ENV_GUARD prefix on all hooks (both template and generated guards)
371
+ // ensures they only activate when LEGIO_AGENT_NAME is set (i.e. for
372
+ // the coordinator's tmux session), so the user's own Claude Code session
373
+ // at the project root is unaffected.
374
+ await deployHooks(projectRoot, COORDINATOR_NAME, "coordinator");
375
+
376
+ // Create coordinator identity if first run
377
+ const identityBaseDir = join(projectRoot, ".legio", "agents");
378
+ await mkdir(identityBaseDir, { recursive: true });
379
+ const existingIdentity = await loadIdentity(identityBaseDir, COORDINATOR_NAME);
380
+ if (!existingIdentity) {
381
+ await createIdentity(identityBaseDir, {
382
+ name: COORDINATOR_NAME,
383
+ capability: "coordinator",
384
+ created: new Date().toISOString(),
385
+ sessionsCompleted: 0,
386
+ expertiseDomains: config.mulch.enabled ? config.mulch.domains : [],
387
+ recentTasks: [],
388
+ });
389
+ }
390
+
391
+ // Resolve model from config > manifest > fallback
392
+ const manifestLoader = createManifestLoader(
393
+ join(projectRoot, config.agents.manifestPath),
394
+ join(projectRoot, config.agents.baseDir),
395
+ );
396
+ const manifest = await manifestLoader.load();
397
+ const model = resolveModel(config, manifest, "coordinator", "opus");
398
+
399
+ // Spawn tmux session at project root with Claude Code (interactive mode).
400
+ // Inject the coordinator base definition via --append-system-prompt so the
401
+ // coordinator knows its role, hierarchy rules, and delegation patterns
402
+ // (legio-gaio, legio-0kwf).
403
+ const agentDefPath = join(projectRoot, ".legio", "agent-defs", "coordinator.md");
404
+ // Build a settings JSON file that: (1) skips the bypass-permissions
405
+ // confirmation dialog, and (2) injects the agent definition as a system
406
+ // prompt suffix. Using --settings with a file avoids Claude Code's
407
+ // ERR_STREAM_DESTROYED crash when --append-system-prompt receives large
408
+ // inline payloads (14KB+).
409
+ const settings: Record<string, unknown> = { skipDangerousModePermissionPrompt: true };
410
+ if (await fileExists(agentDefPath)) {
411
+ settings.appendSystemPrompt = await readFile(agentDefPath, "utf-8");
412
+ }
413
+ const settingsPath = join(legioDir, `settings-${COORDINATOR_NAME}.json`);
414
+ await writeFile(settingsPath, JSON.stringify(settings), "utf-8");
415
+ const claudeCmd = `claude --model ${model} --dangerously-skip-permissions --settings ${settingsPath}`;
416
+
417
+ if (headlessFlag) {
418
+ // ----------------------------------------------------------------
419
+ // Headless path: spawn via HeadlessCoordinator (no tmux)
420
+ // ----------------------------------------------------------------
421
+ const headlessFactory = deps._headless ?? {
422
+ create: (cfg: HeadlessCoordinatorConfig) => new HeadlessCoordinator(cfg),
423
+ };
424
+ const headless = headlessFactory.create({
425
+ command: claudeCmd,
426
+ cwd: projectRoot,
427
+ env: { ...collectProviderEnv(), LEGIO_AGENT_NAME: COORDINATOR_NAME },
428
+ });
429
+
430
+ headless.start();
431
+ const headlessPid = headless.getPid();
432
+
433
+ // Write PID file for later stop/status operations
434
+ if (headlessPid !== null) {
435
+ const pidFilePath = join(legioDir, "headless-coordinator.pid");
436
+ await writeFile(pidFilePath, String(headlessPid), "utf-8");
437
+ }
438
+
439
+ // Record session with tmuxSession="headless" to distinguish from tmux sessions
440
+ const headlessSession: AgentSession = {
441
+ id: `session-${Date.now()}-${COORDINATOR_NAME}`,
442
+ agentName: COORDINATOR_NAME,
443
+ capability: "coordinator",
444
+ worktreePath: projectRoot,
445
+ branchName: config.project.canonicalBranch,
446
+ beadId: "",
447
+ tmuxSession: "headless",
448
+ state: "booting",
449
+ pid: headlessPid,
450
+ parentAgent: null,
451
+ depth: 0,
452
+ runId: null,
453
+ startedAt: new Date().toISOString(),
454
+ lastActivity: new Date().toISOString(),
455
+ escalationLevel: 0,
456
+ stalledSince: null,
457
+ };
458
+
459
+ store.upsert(headlessSession);
460
+
461
+ // Send startup beacon via stdin after initialization delay
462
+ await sleep(3_000);
463
+ const headlessBeacon = buildCoordinatorBeacon();
464
+ try {
465
+ headless.write(`${headlessBeacon}\n`);
466
+ } catch {
467
+ // stdin may not be available if process exited early
468
+ }
469
+
470
+ const headlessOutput = {
471
+ agentName: COORDINATOR_NAME,
472
+ capability: "coordinator",
473
+ headless: true,
474
+ projectRoot,
475
+ pid: headlessPid,
476
+ };
477
+
478
+ if (json) {
479
+ process.stdout.write(`${JSON.stringify(headlessOutput)}\n`);
480
+ } else {
481
+ process.stdout.write("Coordinator started (headless)\n");
482
+ process.stdout.write(` Root: ${projectRoot}\n`);
483
+ process.stdout.write(` PID: ${headlessPid ?? "unknown"}\n`);
484
+ }
485
+
486
+ // Keep process alive until the headless coordinator exits
487
+ await new Promise<void>((resolve) => {
488
+ headless.on("exit", () => resolve());
489
+ });
490
+
491
+ return;
492
+ }
493
+
494
+ // ----------------------------------------------------------------
495
+ // Tmux path: existing behavior
496
+ // ----------------------------------------------------------------
497
+ const pid = await tmux.createSession(tmuxSession, projectRoot, claudeCmd, {
498
+ ...collectProviderEnv(),
499
+ LEGIO_AGENT_NAME: COORDINATOR_NAME,
500
+ });
501
+
502
+ // Record session BEFORE sending the beacon so that hook-triggered
503
+ // updateLastActivity() can find the entry and transition booting->working.
504
+ // Without this, a race exists: hooks fire before the session is persisted,
505
+ // leaving the coordinator stuck in "booting" (legio-036f).
506
+ const session: AgentSession = {
507
+ id: `session-${Date.now()}-${COORDINATOR_NAME}`,
508
+ agentName: COORDINATOR_NAME,
509
+ capability: "coordinator",
510
+ worktreePath: projectRoot, // Coordinator uses project root, not a worktree
511
+ branchName: config.project.canonicalBranch, // Operates on canonical branch
512
+ beadId: "", // No specific bead assignment
513
+ tmuxSession,
514
+ state: "booting",
515
+ pid,
516
+ parentAgent: null, // Top of hierarchy
517
+ depth: 0,
518
+ runId: null,
519
+ startedAt: new Date().toISOString(),
520
+ lastActivity: new Date().toISOString(),
521
+ escalationLevel: 0,
522
+ stalledSince: null,
523
+ };
524
+
525
+ store.upsert(session);
526
+
527
+ // Write output BEFORE the blocking sleep+sendKeys so that callers
528
+ // reading stdout (e.g., runLegio in the server) get the response
529
+ // immediately and don't hang waiting for the pipe to close.
530
+ const output = {
531
+ agentName: COORDINATOR_NAME,
532
+ capability: "coordinator",
533
+ tmuxSession,
534
+ projectRoot,
535
+ pid,
536
+ watchdog: false,
537
+ monitor: false,
538
+ };
539
+
540
+ if (json) {
541
+ process.stdout.write(`${JSON.stringify(output)}\n`);
542
+ } else {
543
+ process.stdout.write("Coordinator started\n");
544
+ process.stdout.write(` Tmux: ${tmuxSession}\n`);
545
+ process.stdout.write(` Root: ${projectRoot}\n`);
546
+ process.stdout.write(` PID: ${pid}\n`);
547
+ }
548
+
549
+ // Wait for Claude Code's TUI to render before sending beacon.
550
+ // Falls back to sleep(3_000) when waitForTuiReady is not in the DI mock.
551
+ if (tmux.waitForTuiReady) {
552
+ await tmux.waitForTuiReady(tmuxSession);
553
+ } else {
554
+ await sleep(3_000);
555
+ }
556
+ const beacon = buildCoordinatorBeacon();
557
+ await tmux.sendKeys(tmuxSession, beacon);
558
+
559
+ // Follow-up Enter to ensure submission (same pattern as sling.ts)
560
+ await sleep(500);
561
+ await tmux.sendKeys(tmuxSession, "");
562
+
563
+ // Auto-start watchdog if --watchdog flag is present
564
+ if (watchdogFlag) {
565
+ const watchdogResult = await watchdog.start();
566
+ if (watchdogResult) {
567
+ if (!json) process.stdout.write(` Watchdog: started (PID ${watchdogResult.pid})\n`);
568
+ } else {
569
+ if (!json) process.stderr.write(" Watchdog: failed to start or already running\n");
570
+ }
571
+ }
572
+
573
+ // Auto-start monitor if --monitor flag is present
574
+ if (monitorFlag) {
575
+ const monitorResult = await monitor.start([]);
576
+ if (monitorResult) {
577
+ if (!json) process.stdout.write(` Monitor: started (PID ${monitorResult.pid})\n`);
578
+ } else {
579
+ if (!json) process.stderr.write(" Monitor: failed to start or already running\n");
580
+ }
581
+ }
582
+
583
+ if (shouldAttach) {
584
+ spawnSync("tmux", ["attach-session", "-t", tmuxSession], {
585
+ stdio: ["inherit", "inherit", "inherit"],
586
+ });
587
+ }
588
+ } finally {
589
+ store.close();
590
+ }
591
+ }
592
+
593
+ /**
594
+ * Stop the coordinator agent.
595
+ *
596
+ * 1. Find the active coordinator session
597
+ * 2. Kill the tmux session (with process tree cleanup)
598
+ * 3. Mark session as completed in SessionStore
599
+ * 4. Auto-complete the active run (if current-run.txt exists)
600
+ */
601
+ async function stopCoordinator(args: string[], deps: CoordinatorDeps = {}): Promise<void> {
602
+ const tmux = deps._tmux ?? { createSession, isSessionAlive, killSession, sendKeys };
603
+
604
+ const json = args.includes("--json");
605
+ const cwd = process.cwd();
606
+ const config = await loadConfig(cwd);
607
+ const projectRoot = config.project.root;
608
+ const watchdog = deps._watchdog ?? createDefaultWatchdog(projectRoot);
609
+ const monitor = deps._monitor ?? createDefaultMonitor(projectRoot);
610
+
611
+ const legioDir = join(projectRoot, ".legio");
612
+ const { store } = openSessionStore(legioDir);
613
+ try {
614
+ const session = store.getByName(COORDINATOR_NAME);
615
+
616
+ if (
617
+ !session ||
618
+ session.capability !== "coordinator" ||
619
+ session.state === "completed" ||
620
+ session.state === "zombie"
621
+ ) {
622
+ throw new AgentError("No active coordinator session found", {
623
+ agentName: COORDINATOR_NAME,
624
+ });
625
+ }
626
+
627
+ if (session.tmuxSession === "headless") {
628
+ // ----------------------------------------------------------------
629
+ // Headless stop: read PID from file and kill the process
630
+ // ----------------------------------------------------------------
631
+ const pidFilePath = join(legioDir, "headless-coordinator.pid");
632
+ let headlessPid: number | null = null;
633
+ try {
634
+ const pidText = await readFile(pidFilePath, "utf-8");
635
+ const parsed = Number.parseInt(pidText.trim(), 10);
636
+ if (!Number.isNaN(parsed) && parsed > 0) {
637
+ headlessPid = parsed;
638
+ }
639
+ } catch {
640
+ // PID file may not exist — process may already be dead
641
+ }
642
+
643
+ if (headlessPid !== null && isProcessRunning(headlessPid)) {
644
+ try {
645
+ process.kill(headlessPid, 15); // SIGTERM
646
+ } catch {
647
+ // ignore — process may have already exited
648
+ }
649
+ }
650
+
651
+ // Clean up PID file
652
+ try {
653
+ await unlink(pidFilePath);
654
+ } catch {
655
+ // File may already be gone
656
+ }
657
+ } else {
658
+ // Kill tmux session with process tree cleanup
659
+ const alive = await tmux.isSessionAlive(session.tmuxSession);
660
+ if (alive) {
661
+ await tmux.killSession(session.tmuxSession);
662
+ }
663
+ }
664
+
665
+ // Always attempt to stop watchdog
666
+ const watchdogStopped = await watchdog.stop();
667
+
668
+ // Always attempt to stop monitor
669
+ const monitorStopped = await monitor.stop();
670
+
671
+ // Update session state
672
+ store.updateState(COORDINATOR_NAME, "completed");
673
+ store.updateLastActivity(COORDINATOR_NAME);
674
+
675
+ // Auto-complete the current run
676
+ let runCompleted = false;
677
+ try {
678
+ const currentRunPath = join(legioDir, "current-run.txt");
679
+ if (await fileExists(currentRunPath)) {
680
+ const runId = (await readFile(currentRunPath, "utf-8")).trim();
681
+ if (runId.length > 0) {
682
+ const runStore = createRunStore(join(legioDir, "sessions.db"));
683
+ try {
684
+ runStore.completeRun(runId, "completed");
685
+ runCompleted = true;
686
+ } finally {
687
+ runStore.close();
688
+ }
689
+ try {
690
+ await unlink(currentRunPath);
691
+ } catch {
692
+ // File may already be gone
693
+ }
694
+ }
695
+ }
696
+ } catch {
697
+ // Non-fatal: run completion should not break coordinator stop
698
+ }
699
+
700
+ if (json) {
701
+ process.stdout.write(
702
+ `${JSON.stringify({ stopped: true, sessionId: session.id, watchdogStopped, monitorStopped, runCompleted })}\n`,
703
+ );
704
+ } else {
705
+ process.stdout.write(`Coordinator stopped (session: ${session.id})\n`);
706
+ if (watchdogStopped) {
707
+ process.stdout.write("Watchdog stopped\n");
708
+ } else {
709
+ process.stdout.write("No watchdog running\n");
710
+ }
711
+ if (monitorStopped) {
712
+ process.stdout.write("Monitor stopped\n");
713
+ } else {
714
+ process.stdout.write("No monitor running\n");
715
+ }
716
+ if (runCompleted) {
717
+ process.stdout.write("Run completed\n");
718
+ } else {
719
+ process.stdout.write("No active run\n");
720
+ }
721
+ }
722
+ } finally {
723
+ store.close();
724
+ }
725
+ }
726
+
727
+ /**
728
+ * Show coordinator status.
729
+ *
730
+ * Checks session registry and tmux liveness to report actual state.
731
+ */
732
+ async function statusCoordinator(args: string[], deps: CoordinatorDeps = {}): Promise<void> {
733
+ const tmux = deps._tmux ?? { createSession, isSessionAlive, killSession, sendKeys };
734
+
735
+ const json = args.includes("--json");
736
+ const cwd = process.cwd();
737
+ const config = await loadConfig(cwd);
738
+ const projectRoot = config.project.root;
739
+ const watchdog = deps._watchdog ?? createDefaultWatchdog(projectRoot);
740
+ const monitor = deps._monitor ?? createDefaultMonitor(projectRoot);
741
+
742
+ const legioDir = join(projectRoot, ".legio");
743
+ const { store } = openSessionStore(legioDir);
744
+ try {
745
+ const session = store.getByName(COORDINATOR_NAME);
746
+ const watchdogRunning = await watchdog.isRunning();
747
+ const monitorRunning = await monitor.isRunning();
748
+
749
+ if (
750
+ !session ||
751
+ session.capability !== "coordinator" ||
752
+ session.state === "completed" ||
753
+ session.state === "zombie"
754
+ ) {
755
+ if (json) {
756
+ process.stdout.write(
757
+ `${JSON.stringify({ running: false, watchdogRunning, monitorRunning })}\n`,
758
+ );
759
+ } else {
760
+ process.stdout.write("Coordinator is not running\n");
761
+ if (watchdogRunning) {
762
+ process.stdout.write("Watchdog: running\n");
763
+ }
764
+ if (monitorRunning) {
765
+ process.stdout.write("Monitor: running\n");
766
+ }
767
+ }
768
+ return;
769
+ }
770
+
771
+ const isHeadless = session.tmuxSession === "headless";
772
+
773
+ // For headless sessions, liveness is determined by PID; for tmux sessions, by session.
774
+ let alive: boolean;
775
+ if (isHeadless) {
776
+ alive = session.pid !== null && isProcessRunning(session.pid);
777
+ } else {
778
+ alive = await tmux.isSessionAlive(session.tmuxSession);
779
+ }
780
+
781
+ // Reconcile state: if session says active but process/tmux is dead, update.
782
+ // We already filtered out completed/zombie states above, so if dead
783
+ // this session needs to be marked as zombie.
784
+ if (!alive) {
785
+ store.updateState(COORDINATOR_NAME, "zombie");
786
+ session.state = "zombie";
787
+ }
788
+ const status = {
789
+ running: alive,
790
+ headless: isHeadless,
791
+ sessionId: session.id,
792
+ state: session.state,
793
+ tmuxSession: session.tmuxSession,
794
+ pid: session.pid,
795
+ startedAt: session.startedAt,
796
+ lastActivity: session.lastActivity,
797
+ watchdogRunning,
798
+ monitorRunning,
799
+ };
800
+
801
+ if (json) {
802
+ process.stdout.write(`${JSON.stringify(status)}\n`);
803
+ } else {
804
+ const stateLabel = alive ? "running" : session.state;
805
+ process.stdout.write(`Coordinator: ${stateLabel}\n`);
806
+ process.stdout.write(` Session: ${session.id}\n`);
807
+ process.stdout.write(` Tmux: ${session.tmuxSession}\n`);
808
+ process.stdout.write(` PID: ${session.pid}\n`);
809
+ process.stdout.write(` Started: ${session.startedAt}\n`);
810
+ process.stdout.write(` Activity: ${session.lastActivity}\n`);
811
+ process.stdout.write(` Watchdog: ${watchdogRunning ? "running" : "not running"}\n`);
812
+ process.stdout.write(` Monitor: ${monitorRunning ? "running" : "not running"}\n`);
813
+ }
814
+ } finally {
815
+ store.close();
816
+ }
817
+ }
818
+
819
+ const COORDINATOR_HELP = `legio coordinator — Manage the persistent coordinator agent
820
+
821
+ Usage: legio coordinator <subcommand> [flags]
822
+
823
+ Subcommands:
824
+ start Start the coordinator (spawns Claude Code at project root)
825
+ stop Stop the coordinator (kills tmux session)
826
+ status Show coordinator state
827
+
828
+ Start options:
829
+ --attach Always attach to tmux session after start
830
+ --no-attach Never attach to tmux session after start
831
+ Default: attach when running in an interactive TTY
832
+ --headless Start without tmux — use PTY subprocess (no terminal UI)
833
+ --watchdog Auto-start watchdog daemon with coordinator
834
+ --monitor Auto-start monitor agent (Tier 2) with coordinator
835
+
836
+ General options:
837
+ --json Output as JSON
838
+ --help, -h Show this help
839
+
840
+ The coordinator runs at the project root and orchestrates work by:
841
+ - Decomposing objectives into beads issues
842
+ - Dispatching agents via legio sling
843
+ - Tracking batches via task groups
844
+ - Handling escalations from agents and watchdog`;
845
+
846
+ /**
847
+ * Entry point for `legio coordinator <subcommand>`.
848
+ *
849
+ * @param args - CLI arguments after "coordinator"
850
+ * @param deps - Optional dependency injection for testing (tmux)
851
+ */
852
+ export async function coordinatorCommand(
853
+ args: string[],
854
+ deps: CoordinatorDeps = {},
855
+ ): Promise<void> {
856
+ if (args.includes("--help") || args.includes("-h") || args.length === 0) {
857
+ process.stdout.write(`${COORDINATOR_HELP}\n`);
858
+ return;
859
+ }
860
+
861
+ const subcommand = args[0];
862
+ const subArgs = args.slice(1);
863
+
864
+ switch (subcommand) {
865
+ case "start":
866
+ await startCoordinator(subArgs, deps);
867
+ break;
868
+ case "stop":
869
+ await stopCoordinator(subArgs, deps);
870
+ break;
871
+ case "status":
872
+ await statusCoordinator(subArgs, deps);
873
+ break;
874
+ default:
875
+ throw new ValidationError(
876
+ `Unknown coordinator subcommand: ${subcommand}. Run 'legio coordinator --help' for usage.`,
877
+ { field: "subcommand", value: subcommand },
878
+ );
879
+ }
880
+ }