@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,1524 @@
1
+ /**
2
+ * Tests for legio coordinator command.
3
+ *
4
+ * Uses real temp directories and real git repos for file I/O and config loading.
5
+ * Tmux is injected via the CoordinatorDeps DI interface instead of
6
+ * mock.module() to avoid the process-global mock leak issue
7
+ * (see mulch record mx-56558b).
8
+ *
9
+ * WHY DI instead of mock.module: mock.module() in vitest is process-global
10
+ * and leaks across test files. The DI approach (same pattern as daemon.ts
11
+ * _tmux/_triage/_nudge) ensures mocks are scoped to each test invocation.
12
+ */
13
+
14
+ import { statSync } from "node:fs";
15
+ import { access, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
16
+ import { join } from "node:path";
17
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
18
+ import { AgentError, ValidationError } from "../errors.ts";
19
+ import { openSessionStore } from "../sessions/compat.ts";
20
+ import { createRunStore } from "../sessions/store.ts";
21
+ import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
22
+ import type { AgentSession } from "../types.ts";
23
+ import {
24
+ buildCoordinatorBeacon,
25
+ type CoordinatorDeps,
26
+ coordinatorCommand,
27
+ resolveAttach,
28
+ } from "./coordinator.ts";
29
+
30
+ // --- Fake Tmux ---
31
+
32
+ /** Track calls to fake tmux for assertions. */
33
+ interface TmuxCallTracker {
34
+ createSession: Array<{
35
+ name: string;
36
+ cwd: string;
37
+ command: string;
38
+ env?: Record<string, string>;
39
+ }>;
40
+ isSessionAlive: Array<{ name: string; result: boolean }>;
41
+ killSession: Array<{ name: string }>;
42
+ sendKeys: Array<{ name: string; keys: string }>;
43
+ }
44
+
45
+ // --- Fake Watchdog ---
46
+
47
+ /** Track calls to fake watchdog for assertions. */
48
+ interface WatchdogCallTracker {
49
+ start: number;
50
+ stop: number;
51
+ isRunning: number;
52
+ }
53
+
54
+ // --- Fake Monitor ---
55
+
56
+ /** Track calls to fake monitor for assertions. */
57
+ interface MonitorCallTracker {
58
+ start: number;
59
+ stop: number;
60
+ isRunning: number;
61
+ }
62
+
63
+ /** Build a fake tmux DI object with configurable session liveness. */
64
+ function makeFakeTmux(sessionAliveMap: Record<string, boolean> = {}): {
65
+ tmux: NonNullable<CoordinatorDeps["_tmux"]>;
66
+ calls: TmuxCallTracker;
67
+ } {
68
+ const calls: TmuxCallTracker = {
69
+ createSession: [],
70
+ isSessionAlive: [],
71
+ killSession: [],
72
+ sendKeys: [],
73
+ };
74
+
75
+ const tmux: NonNullable<CoordinatorDeps["_tmux"]> = {
76
+ createSession: async (
77
+ name: string,
78
+ cwd: string,
79
+ command: string,
80
+ env?: Record<string, string>,
81
+ ): Promise<number> => {
82
+ calls.createSession.push({ name, cwd, command, env });
83
+ return 99999; // Fake PID
84
+ },
85
+ isSessionAlive: async (name: string): Promise<boolean> => {
86
+ const alive = sessionAliveMap[name] ?? false;
87
+ calls.isSessionAlive.push({ name, result: alive });
88
+ return alive;
89
+ },
90
+ killSession: async (name: string): Promise<void> => {
91
+ calls.killSession.push({ name });
92
+ },
93
+ sendKeys: async (name: string, keys: string): Promise<void> => {
94
+ calls.sendKeys.push({ name, keys });
95
+ },
96
+ };
97
+
98
+ return { tmux, calls };
99
+ }
100
+
101
+ /**
102
+ * Build a fake watchdog DI object with configurable behavior.
103
+ * @param running - Whether the watchdog should report as running
104
+ * @param startSuccess - Whether start() should succeed (return a PID)
105
+ * @param stopSuccess - Whether stop() should succeed (return true)
106
+ */
107
+ function makeFakeWatchdog(
108
+ running = false,
109
+ startSuccess = true,
110
+ stopSuccess = true,
111
+ ): {
112
+ watchdog: NonNullable<CoordinatorDeps["_watchdog"]>;
113
+ calls: WatchdogCallTracker;
114
+ } {
115
+ const calls: WatchdogCallTracker = {
116
+ start: 0,
117
+ stop: 0,
118
+ isRunning: 0,
119
+ };
120
+
121
+ const watchdog: NonNullable<CoordinatorDeps["_watchdog"]> = {
122
+ async start(): Promise<{ pid: number } | null> {
123
+ calls.start++;
124
+ return startSuccess ? { pid: 88888 } : null;
125
+ },
126
+ async stop(): Promise<boolean> {
127
+ calls.stop++;
128
+ return stopSuccess;
129
+ },
130
+ async isRunning(): Promise<boolean> {
131
+ calls.isRunning++;
132
+ return running;
133
+ },
134
+ };
135
+
136
+ return { watchdog, calls };
137
+ }
138
+
139
+ /**
140
+ * Build a fake monitor DI object with configurable behavior.
141
+ * @param running - Whether the monitor should report as running
142
+ * @param startSuccess - Whether start() should succeed (return a PID)
143
+ * @param stopSuccess - Whether stop() should succeed (return true)
144
+ */
145
+ function makeFakeMonitor(
146
+ running = false,
147
+ startSuccess = true,
148
+ stopSuccess = true,
149
+ ): {
150
+ monitor: NonNullable<CoordinatorDeps["_monitor"]>;
151
+ calls: MonitorCallTracker;
152
+ } {
153
+ const calls: MonitorCallTracker = {
154
+ start: 0,
155
+ stop: 0,
156
+ isRunning: 0,
157
+ };
158
+
159
+ const monitor: NonNullable<CoordinatorDeps["_monitor"]> = {
160
+ async start(): Promise<{ pid: number } | null> {
161
+ calls.start++;
162
+ return startSuccess ? { pid: 77777 } : null;
163
+ },
164
+ async stop(): Promise<boolean> {
165
+ calls.stop++;
166
+ return stopSuccess;
167
+ },
168
+ async isRunning(): Promise<boolean> {
169
+ calls.isRunning++;
170
+ return running;
171
+ },
172
+ };
173
+
174
+ return { monitor, calls };
175
+ }
176
+
177
+ // --- Test Setup ---
178
+
179
+ let tempDir: string;
180
+ let legioDir: string;
181
+ const originalCwd = process.cwd();
182
+
183
+ /** Save sessions to the SessionStore (sessions.db) for test setup. */
184
+ function saveSessionsToDb(sessions: AgentSession[]): void {
185
+ const { store } = openSessionStore(legioDir);
186
+ try {
187
+ for (const session of sessions) {
188
+ store.upsert(session);
189
+ }
190
+ } finally {
191
+ store.close();
192
+ }
193
+ }
194
+
195
+ /** Load all sessions from the SessionStore (sessions.db). */
196
+ function loadSessionsFromDb(): AgentSession[] {
197
+ const { store } = openSessionStore(legioDir);
198
+ try {
199
+ return store.getAll();
200
+ } finally {
201
+ store.close();
202
+ }
203
+ }
204
+
205
+ beforeEach(async () => {
206
+ // Restore cwd FIRST so createTempGitRepo's git operations don't fail
207
+ // if a prior test's tempDir was already cleaned up.
208
+ process.chdir(originalCwd);
209
+
210
+ tempDir = await realpath(await createTempGitRepo());
211
+ legioDir = join(tempDir, ".legio");
212
+ await mkdir(legioDir, { recursive: true });
213
+
214
+ // Write a minimal config.yaml so loadConfig succeeds
215
+ await writeFile(
216
+ join(legioDir, "config.yaml"),
217
+ ["project:", " name: test-project", ` root: ${tempDir}`, " canonicalBranch: main"].join(
218
+ "\n",
219
+ ),
220
+ );
221
+
222
+ // Write agent-manifest.json and stub agent-def .md files so manifest loading succeeds
223
+ const agentDefsDir = join(legioDir, "agent-defs");
224
+ await mkdir(agentDefsDir, { recursive: true });
225
+ const manifest = {
226
+ version: "1.0",
227
+ agents: {
228
+ coordinator: {
229
+ file: "coordinator.md",
230
+ model: "opus",
231
+ tools: ["Read", "Bash"],
232
+ capabilities: ["coordinate"],
233
+ canSpawn: true,
234
+ constraints: [],
235
+ },
236
+ },
237
+ capabilityIndex: { coordinate: ["coordinator"] },
238
+ };
239
+ await writeFile(
240
+ join(legioDir, "agent-manifest.json"),
241
+ `${JSON.stringify(manifest, null, "\t")}\n`,
242
+ );
243
+ await writeFile(join(agentDefsDir, "coordinator.md"), "# Coordinator\n");
244
+
245
+ // Override cwd so coordinator commands find our temp project
246
+ process.chdir(tempDir);
247
+ });
248
+
249
+ afterEach(async () => {
250
+ process.chdir(originalCwd);
251
+ await cleanupTempDir(tempDir);
252
+ });
253
+
254
+ // --- Helpers ---
255
+
256
+ function makeCoordinatorSession(overrides: Partial<AgentSession> = {}): AgentSession {
257
+ return {
258
+ id: `session-${Date.now()}-coordinator`,
259
+ agentName: "coordinator",
260
+ capability: "coordinator",
261
+ worktreePath: tempDir,
262
+ branchName: "main",
263
+ beadId: "",
264
+ tmuxSession: "legio-test-project-coordinator",
265
+ state: "working",
266
+ pid: 99999,
267
+ parentAgent: null,
268
+ depth: 0,
269
+ runId: null,
270
+ startedAt: new Date().toISOString(),
271
+ lastActivity: new Date().toISOString(),
272
+ escalationLevel: 0,
273
+ stalledSince: null,
274
+ ...overrides,
275
+ };
276
+ }
277
+
278
+ /** Capture stdout.write output during a function call. */
279
+ async function captureStdout(fn: () => Promise<void>): Promise<string> {
280
+ const chunks: string[] = [];
281
+ const originalWrite = process.stdout.write;
282
+ process.stdout.write = ((chunk: string) => {
283
+ chunks.push(chunk);
284
+ return true;
285
+ }) as typeof process.stdout.write;
286
+ try {
287
+ await fn();
288
+ } finally {
289
+ process.stdout.write = originalWrite;
290
+ }
291
+ return chunks.join("");
292
+ }
293
+
294
+ /** Build default CoordinatorDeps with fake tmux, watchdog, and monitor.
295
+ * Always injects fakes for all three to prevent real child_process.spawn(["legio", ...])
296
+ * calls in tests (legio CLI is not available in CI). */
297
+ function makeDeps(
298
+ sessionAliveMap: Record<string, boolean> = {},
299
+ watchdogConfig?: { running?: boolean; startSuccess?: boolean; stopSuccess?: boolean },
300
+ monitorConfig?: { running?: boolean; startSuccess?: boolean; stopSuccess?: boolean },
301
+ ): {
302
+ deps: CoordinatorDeps;
303
+ calls: TmuxCallTracker;
304
+ watchdogCalls: WatchdogCallTracker;
305
+ monitorCalls: MonitorCallTracker;
306
+ } {
307
+ const { tmux, calls } = makeFakeTmux(sessionAliveMap);
308
+ const { watchdog, calls: watchdogCalls } = makeFakeWatchdog(
309
+ watchdogConfig?.running,
310
+ watchdogConfig?.startSuccess,
311
+ watchdogConfig?.stopSuccess,
312
+ );
313
+ const { monitor, calls: monitorCalls } = makeFakeMonitor(
314
+ monitorConfig?.running,
315
+ monitorConfig?.startSuccess,
316
+ monitorConfig?.stopSuccess,
317
+ );
318
+
319
+ const deps: CoordinatorDeps = {
320
+ _tmux: tmux,
321
+ _watchdog: watchdog,
322
+ _monitor: monitor,
323
+ _sleep: () => Promise.resolve(),
324
+ };
325
+
326
+ return {
327
+ deps,
328
+ calls,
329
+ watchdogCalls,
330
+ monitorCalls,
331
+ };
332
+ }
333
+
334
+ // --- Tests ---
335
+
336
+ describe("coordinatorCommand help", () => {
337
+ test("--help outputs help text", async () => {
338
+ const output = await captureStdout(() => coordinatorCommand(["--help"]));
339
+ expect(output).toContain("legio coordinator");
340
+ expect(output).toContain("start");
341
+ expect(output).toContain("stop");
342
+ expect(output).toContain("status");
343
+ });
344
+
345
+ test("--help includes --attach and --no-attach flags", async () => {
346
+ const output = await captureStdout(() => coordinatorCommand(["--help"]));
347
+ expect(output).toContain("--attach");
348
+ expect(output).toContain("--no-attach");
349
+ });
350
+
351
+ test("-h outputs help text", async () => {
352
+ const output = await captureStdout(() => coordinatorCommand(["-h"]));
353
+ expect(output).toContain("legio coordinator");
354
+ });
355
+
356
+ test("empty args outputs help text", async () => {
357
+ const output = await captureStdout(() => coordinatorCommand([]));
358
+ expect(output).toContain("legio coordinator");
359
+ expect(output).toContain("Subcommands:");
360
+ });
361
+ });
362
+
363
+ describe("coordinatorCommand unknown subcommand", () => {
364
+ test("throws ValidationError for unknown subcommand", async () => {
365
+ await expect(coordinatorCommand(["frobnicate"])).rejects.toThrow(ValidationError);
366
+ });
367
+
368
+ test("error message includes the bad subcommand name", async () => {
369
+ try {
370
+ await coordinatorCommand(["frobnicate"]);
371
+ expect.unreachable("should have thrown");
372
+ } catch (err) {
373
+ expect(err).toBeInstanceOf(ValidationError);
374
+ const ve = err as ValidationError;
375
+ expect(ve.message).toContain("frobnicate");
376
+ expect(ve.field).toBe("subcommand");
377
+ expect(ve.value).toBe("frobnicate");
378
+ }
379
+ });
380
+ });
381
+
382
+ describe("startCoordinator", () => {
383
+ test("writes session to sessions.json with correct fields", async () => {
384
+ const { deps, calls } = makeDeps();
385
+
386
+ await captureStdout(() => coordinatorCommand(["start"], deps));
387
+
388
+ // Verify sessions.json was written
389
+ const sessions = loadSessionsFromDb();
390
+ expect(sessions).toHaveLength(1);
391
+
392
+ const session = sessions[0];
393
+ expect(session).toBeDefined();
394
+ expect(session?.agentName).toBe("coordinator");
395
+ expect(session?.capability).toBe("coordinator");
396
+ expect(session?.tmuxSession).toBe("legio-test-project-coordinator");
397
+ expect(session?.state).toBe("booting");
398
+ expect(session?.pid).toBe(99999);
399
+ expect(session?.parentAgent).toBeNull();
400
+ expect(session?.depth).toBe(0);
401
+ expect(session?.beadId).toBe("");
402
+ expect(session?.branchName).toBe("main");
403
+ expect(session?.worktreePath).toBe(tempDir);
404
+ expect(session?.id).toMatch(/^session-\d+-coordinator$/);
405
+
406
+ // Verify tmux createSession was called
407
+ expect(calls.createSession).toHaveLength(1);
408
+ expect(calls.createSession[0]?.name).toBe("legio-test-project-coordinator");
409
+ expect(calls.createSession[0]?.cwd).toBe(tempDir);
410
+
411
+ // Verify sendKeys was called (beacon + follow-up Enter)
412
+ expect(calls.sendKeys.length).toBeGreaterThanOrEqual(1);
413
+ });
414
+
415
+ test("deploys hooks to project root .claude/settings.local.json", async () => {
416
+ const { deps } = makeDeps();
417
+
418
+ await captureStdout(() => coordinatorCommand(["start", "--no-attach"], deps));
419
+
420
+ // Verify .claude/settings.local.json was created at the project root
421
+ const settingsPath = join(tempDir, ".claude", "settings.local.json");
422
+ let settingsExists = false;
423
+ try {
424
+ await access(settingsPath);
425
+ settingsExists = true;
426
+ } catch {
427
+ // not found
428
+ }
429
+ expect(settingsExists).toBe(true);
430
+
431
+ const content = await readFile(settingsPath, "utf-8");
432
+ const config = JSON.parse(content) as {
433
+ hooks: Record<string, unknown[]>;
434
+ };
435
+
436
+ // Verify hook categories exist
437
+ expect(config.hooks).toBeDefined();
438
+ expect(config.hooks.SessionStart).toBeDefined();
439
+ expect(config.hooks.UserPromptSubmit).toBeDefined();
440
+ expect(config.hooks.PreToolUse).toBeDefined();
441
+ expect(config.hooks.PostToolUse).toBeDefined();
442
+ expect(config.hooks.Stop).toBeDefined();
443
+ });
444
+
445
+ test("hooks use coordinator agent name for event logging", async () => {
446
+ const { deps } = makeDeps();
447
+
448
+ await captureStdout(() => coordinatorCommand(["start", "--no-attach"], deps));
449
+
450
+ const settingsPath = join(tempDir, ".claude", "settings.local.json");
451
+ const content = await readFile(settingsPath, "utf-8");
452
+
453
+ // The hooks should reference the coordinator agent name
454
+ expect(content).toContain("--agent coordinator");
455
+ });
456
+
457
+ test("hooks include ENV_GUARD to avoid affecting user's Claude Code session", async () => {
458
+ const { deps } = makeDeps();
459
+
460
+ await captureStdout(() => coordinatorCommand(["start", "--no-attach"], deps));
461
+
462
+ const settingsPath = join(tempDir, ".claude", "settings.local.json");
463
+ const content = await readFile(settingsPath, "utf-8");
464
+
465
+ // PreToolUse guards should include the ENV_GUARD prefix
466
+ expect(content).toContain("LEGIO_AGENT_NAME");
467
+ });
468
+
469
+ test("injects agent definition via --settings file when agent-defs/coordinator.md exists", async () => {
470
+ // Deploy a coordinator agent definition
471
+ const agentDefsDir = join(legioDir, "agent-defs");
472
+ await mkdir(agentDefsDir, { recursive: true });
473
+ await writeFile(
474
+ join(agentDefsDir, "coordinator.md"),
475
+ "# Coordinator Agent\n\nYou are the coordinator.\n",
476
+ );
477
+
478
+ const { deps, calls } = makeDeps();
479
+
480
+ await captureStdout(() => coordinatorCommand(["start", "--no-attach", "--json"], deps));
481
+
482
+ expect(calls.createSession).toHaveLength(1);
483
+ const cmd = calls.createSession[0]?.command ?? "";
484
+ // Agent def is written to a settings JSON file and passed via --settings
485
+ // to avoid Claude Code's ERR_STREAM_DESTROYED with large inline payloads
486
+ expect(cmd).toContain("--settings");
487
+ expect(cmd).toContain("settings-coordinator.json");
488
+
489
+ // Verify the settings file was written with the agent def
490
+ const { readFileSync } = await import("node:fs");
491
+ const settingsPath = join(legioDir, "settings-coordinator.json");
492
+ const settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as Record<string, unknown>;
493
+ expect(settings.appendSystemPrompt).toContain("# Coordinator Agent");
494
+ expect(settings.skipDangerousModePermissionPrompt).toBe(true);
495
+ });
496
+
497
+ test("reads model from manifest instead of hardcoding", async () => {
498
+ // Override the manifest to use sonnet instead of default opus
499
+ const manifest = {
500
+ version: "1.0",
501
+ agents: {
502
+ coordinator: {
503
+ file: "coordinator.md",
504
+ model: "sonnet",
505
+ tools: ["Read", "Bash"],
506
+ capabilities: ["coordinate"],
507
+ canSpawn: true,
508
+ constraints: [],
509
+ },
510
+ },
511
+ capabilityIndex: { coordinate: ["coordinator"] },
512
+ };
513
+ await writeFile(
514
+ join(legioDir, "agent-manifest.json"),
515
+ `${JSON.stringify(manifest, null, "\t")}\n`,
516
+ );
517
+
518
+ const { deps, calls } = makeDeps();
519
+
520
+ await captureStdout(() => coordinatorCommand(["start", "--no-attach", "--json"], deps));
521
+
522
+ expect(calls.createSession).toHaveLength(1);
523
+ const cmd = calls.createSession[0]?.command ?? "";
524
+ expect(cmd).toContain("--model sonnet");
525
+ expect(cmd).not.toContain("--model opus");
526
+ });
527
+
528
+ test("--json outputs JSON with expected fields", async () => {
529
+ const { deps } = makeDeps();
530
+
531
+ const output = await captureStdout(() => coordinatorCommand(["start", "--json"], deps));
532
+
533
+ const parsed = JSON.parse(output) as Record<string, unknown>;
534
+ expect(parsed.agentName).toBe("coordinator");
535
+ expect(parsed.capability).toBe("coordinator");
536
+ expect(parsed.tmuxSession).toBe("legio-test-project-coordinator");
537
+ expect(parsed.pid).toBe(99999);
538
+ expect(parsed.projectRoot).toBe(tempDir);
539
+ });
540
+
541
+ test("rejects duplicate when coordinator is already running", async () => {
542
+ // Write an existing active coordinator session
543
+ const existing = makeCoordinatorSession({ state: "working" });
544
+ saveSessionsToDb([existing]);
545
+
546
+ // Mock tmux as alive for the existing session
547
+ const { deps } = makeDeps({ "legio-test-project-coordinator": true });
548
+
549
+ await expect(coordinatorCommand(["start"], deps)).rejects.toThrow(AgentError);
550
+
551
+ try {
552
+ await coordinatorCommand(["start"], deps);
553
+ } catch (err) {
554
+ expect(err).toBeInstanceOf(AgentError);
555
+ const ae = err as AgentError;
556
+ expect(ae.message).toContain("already running");
557
+ }
558
+ });
559
+
560
+ test("cleans up dead session and starts new one", async () => {
561
+ // Write an existing session that claims to be working
562
+ const deadSession = makeCoordinatorSession({
563
+ id: "session-dead-coordinator",
564
+ state: "working",
565
+ });
566
+ saveSessionsToDb([deadSession]);
567
+
568
+ // Mock tmux as NOT alive for the existing session
569
+ const { deps } = makeDeps({ "legio-test-project-coordinator": false });
570
+
571
+ await captureStdout(() => coordinatorCommand(["start"], deps));
572
+
573
+ // SessionStore uses UNIQUE(agent_name), so the new session replaces the old one.
574
+ // Verify the new session is in booting state with the coordinator name.
575
+ const sessions = loadSessionsFromDb();
576
+ expect(sessions).toHaveLength(1);
577
+
578
+ const newSession = sessions[0];
579
+ expect(newSession).toBeDefined();
580
+ expect(newSession?.state).toBe("booting");
581
+ expect(newSession?.agentName).toBe("coordinator");
582
+ // The new session should have a different ID than the dead one
583
+ expect(newSession?.id).not.toBe("session-dead-coordinator");
584
+ });
585
+ });
586
+
587
+ describe("stopCoordinator", () => {
588
+ test("marks session as completed after stopping", async () => {
589
+ const session = makeCoordinatorSession({ state: "working" });
590
+ saveSessionsToDb([session]);
591
+
592
+ // Tmux is alive so killSession will be called
593
+ const { deps, calls } = makeDeps({ "legio-test-project-coordinator": true });
594
+
595
+ await captureStdout(() => coordinatorCommand(["stop"], deps));
596
+
597
+ // Verify session is now completed
598
+ const sessions = loadSessionsFromDb();
599
+ expect(sessions).toHaveLength(1);
600
+ expect(sessions[0]?.state).toBe("completed");
601
+
602
+ // Verify killSession was called
603
+ expect(calls.killSession).toHaveLength(1);
604
+ expect(calls.killSession[0]?.name).toBe("legio-test-project-coordinator");
605
+ });
606
+
607
+ test("--json outputs JSON with stopped flag", async () => {
608
+ const session = makeCoordinatorSession({ state: "working" });
609
+ saveSessionsToDb([session]);
610
+ const { deps } = makeDeps({ "legio-test-project-coordinator": true });
611
+
612
+ const output = await captureStdout(() => coordinatorCommand(["stop", "--json"], deps));
613
+ const parsed = JSON.parse(output) as Record<string, unknown>;
614
+ expect(parsed.stopped).toBe(true);
615
+ expect(parsed.sessionId).toBe(session.id);
616
+ });
617
+
618
+ test("handles already-dead tmux session gracefully", async () => {
619
+ const session = makeCoordinatorSession({ state: "working" });
620
+ saveSessionsToDb([session]);
621
+
622
+ // Tmux is NOT alive — should skip killSession
623
+ const { deps, calls } = makeDeps({ "legio-test-project-coordinator": false });
624
+
625
+ await captureStdout(() => coordinatorCommand(["stop"], deps));
626
+
627
+ // Verify session is completed
628
+ const sessions = loadSessionsFromDb();
629
+ expect(sessions[0]?.state).toBe("completed");
630
+
631
+ // killSession should NOT have been called since session was already dead
632
+ expect(calls.killSession).toHaveLength(0);
633
+ });
634
+
635
+ test("throws AgentError when no coordinator session exists", async () => {
636
+ const { deps } = makeDeps();
637
+
638
+ // No sessions.json at all
639
+ await expect(coordinatorCommand(["stop"], deps)).rejects.toThrow(AgentError);
640
+
641
+ try {
642
+ await coordinatorCommand(["stop"], deps);
643
+ } catch (err) {
644
+ expect(err).toBeInstanceOf(AgentError);
645
+ const ae = err as AgentError;
646
+ expect(ae.message).toContain("No active coordinator session");
647
+ }
648
+ });
649
+
650
+ test("throws AgentError when only completed sessions exist", async () => {
651
+ const completed = makeCoordinatorSession({ state: "completed" });
652
+ saveSessionsToDb([completed]);
653
+ const { deps } = makeDeps();
654
+
655
+ await expect(coordinatorCommand(["stop"], deps)).rejects.toThrow(AgentError);
656
+ });
657
+ });
658
+
659
+ describe("stopCoordinator run completion", () => {
660
+ test("coordinator stop auto-completes the active run", async () => {
661
+ // Create a coordinator session
662
+ const session = makeCoordinatorSession({ state: "working" });
663
+ saveSessionsToDb([session]);
664
+
665
+ // Create a run in RunStore
666
+ const dbPath = join(legioDir, "sessions.db");
667
+ const runStore = createRunStore(dbPath);
668
+ runStore.createRun({
669
+ id: "run-test-123",
670
+ startedAt: new Date().toISOString(),
671
+ coordinatorSessionId: null,
672
+ status: "active",
673
+ });
674
+ runStore.close();
675
+
676
+ // Write current-run.txt
677
+ await writeFile(join(legioDir, "current-run.txt"), "run-test-123");
678
+
679
+ // Stop coordinator
680
+ const { deps } = makeDeps({ "legio-test-project-coordinator": true });
681
+ await captureStdout(() => coordinatorCommand(["stop"], deps));
682
+
683
+ // Verify run status is "completed"
684
+ const runStoreCheck = createRunStore(dbPath);
685
+ const run = runStoreCheck.getRun("run-test-123");
686
+ runStoreCheck.close();
687
+ expect(run?.status).toBe("completed");
688
+
689
+ // Verify current-run.txt is deleted
690
+ let currentRunExists = false;
691
+ try {
692
+ await access(join(legioDir, "current-run.txt"));
693
+ currentRunExists = true;
694
+ } catch {
695
+ // expected
696
+ }
697
+ expect(currentRunExists).toBe(false);
698
+ });
699
+
700
+ test("coordinator stop succeeds when no active run exists", async () => {
701
+ // Create a coordinator session
702
+ const session = makeCoordinatorSession({ state: "working" });
703
+ saveSessionsToDb([session]);
704
+
705
+ // No current-run.txt
706
+
707
+ // Stop coordinator (should succeed without errors)
708
+ const { deps } = makeDeps({ "legio-test-project-coordinator": true });
709
+ await expect(captureStdout(() => coordinatorCommand(["stop"], deps))).resolves.toBeDefined();
710
+
711
+ // Verify session is completed
712
+ const sessions = loadSessionsFromDb();
713
+ expect(sessions[0]?.state).toBe("completed");
714
+ });
715
+
716
+ test("coordinator stop succeeds when current-run.txt is empty", async () => {
717
+ // Create a coordinator session
718
+ const session = makeCoordinatorSession({ state: "working" });
719
+ saveSessionsToDb([session]);
720
+
721
+ // Write empty current-run.txt
722
+ await writeFile(join(legioDir, "current-run.txt"), "");
723
+
724
+ // Stop coordinator (should succeed without errors)
725
+ const { deps } = makeDeps({ "legio-test-project-coordinator": true });
726
+ await expect(captureStdout(() => coordinatorCommand(["stop"], deps))).resolves.toBeDefined();
727
+
728
+ // Verify session is completed
729
+ const sessions = loadSessionsFromDb();
730
+ expect(sessions[0]?.state).toBe("completed");
731
+ });
732
+
733
+ test("--json output includes runCompleted field", async () => {
734
+ // Create a coordinator session
735
+ const session = makeCoordinatorSession({ state: "working" });
736
+ saveSessionsToDb([session]);
737
+
738
+ // Create a run in RunStore
739
+ const dbPath = join(legioDir, "sessions.db");
740
+ const runStore = createRunStore(dbPath);
741
+ runStore.createRun({
742
+ id: "run-test-456",
743
+ startedAt: new Date().toISOString(),
744
+ coordinatorSessionId: null,
745
+ status: "active",
746
+ });
747
+ runStore.close();
748
+
749
+ // Write current-run.txt
750
+ await writeFile(join(legioDir, "current-run.txt"), "run-test-456");
751
+
752
+ // Stop coordinator with --json
753
+ const { deps } = makeDeps({ "legio-test-project-coordinator": true });
754
+ const output = await captureStdout(() => coordinatorCommand(["stop", "--json"], deps));
755
+
756
+ // Verify output includes runCompleted: true
757
+ const parsed = JSON.parse(output) as Record<string, unknown>;
758
+ expect(parsed.runCompleted).toBe(true);
759
+ });
760
+
761
+ test("--json output includes runCompleted:false when no run", async () => {
762
+ // Create a coordinator session
763
+ const session = makeCoordinatorSession({ state: "working" });
764
+ saveSessionsToDb([session]);
765
+
766
+ // No current-run.txt
767
+
768
+ // Stop coordinator with --json
769
+ const { deps } = makeDeps({ "legio-test-project-coordinator": true });
770
+ const output = await captureStdout(() => coordinatorCommand(["stop", "--json"], deps));
771
+
772
+ // Verify output includes runCompleted: false
773
+ const parsed = JSON.parse(output) as Record<string, unknown>;
774
+ expect(parsed.runCompleted).toBe(false);
775
+ });
776
+ });
777
+
778
+ describe("statusCoordinator", () => {
779
+ test("shows 'not running' when no session exists", async () => {
780
+ const { deps } = makeDeps();
781
+ const output = await captureStdout(() => coordinatorCommand(["status"], deps));
782
+ expect(output).toContain("not running");
783
+ });
784
+
785
+ test("--json shows running:false when no session exists", async () => {
786
+ const { deps } = makeDeps();
787
+ const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
788
+ const parsed = JSON.parse(output) as Record<string, unknown>;
789
+ expect(parsed.running).toBe(false);
790
+ });
791
+
792
+ test("shows running state when coordinator is alive", async () => {
793
+ const session = makeCoordinatorSession({ state: "working" });
794
+ saveSessionsToDb([session]);
795
+ const { deps } = makeDeps({ "legio-test-project-coordinator": true });
796
+
797
+ const output = await captureStdout(() => coordinatorCommand(["status"], deps));
798
+ expect(output).toContain("running");
799
+ expect(output).toContain(session.id);
800
+ expect(output).toContain("legio-test-project-coordinator");
801
+ });
802
+
803
+ test("--json shows correct fields when running", async () => {
804
+ const session = makeCoordinatorSession({ state: "working", pid: 99999 });
805
+ saveSessionsToDb([session]);
806
+ const { deps } = makeDeps({ "legio-test-project-coordinator": true });
807
+
808
+ const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
809
+ const parsed = JSON.parse(output) as Record<string, unknown>;
810
+ expect(parsed.running).toBe(true);
811
+ expect(parsed.sessionId).toBe(session.id);
812
+ expect(parsed.state).toBe("working");
813
+ expect(parsed.tmuxSession).toBe("legio-test-project-coordinator");
814
+ expect(parsed.pid).toBe(99999);
815
+ });
816
+
817
+ test("reconciles zombie: updates state when tmux is dead but session says working", async () => {
818
+ const session = makeCoordinatorSession({ state: "working" });
819
+ saveSessionsToDb([session]);
820
+
821
+ // Tmux is NOT alive — triggers zombie reconciliation
822
+ const { deps } = makeDeps({ "legio-test-project-coordinator": false });
823
+
824
+ const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
825
+ const parsed = JSON.parse(output) as Record<string, unknown>;
826
+ expect(parsed.running).toBe(false);
827
+ expect(parsed.state).toBe("zombie");
828
+
829
+ // Verify sessions.json was updated
830
+ const sessions = loadSessionsFromDb();
831
+ expect(sessions[0]?.state).toBe("zombie");
832
+ });
833
+
834
+ test("reconciles zombie for booting state too", async () => {
835
+ const session = makeCoordinatorSession({ state: "booting" });
836
+ saveSessionsToDb([session]);
837
+ const { deps } = makeDeps({ "legio-test-project-coordinator": false });
838
+
839
+ const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
840
+ const parsed = JSON.parse(output) as Record<string, unknown>;
841
+ expect(parsed.state).toBe("zombie");
842
+ });
843
+
844
+ test("does not show completed sessions as active", async () => {
845
+ const completed = makeCoordinatorSession({ state: "completed" });
846
+ saveSessionsToDb([completed]);
847
+ const { deps } = makeDeps();
848
+
849
+ const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
850
+ const parsed = JSON.parse(output) as Record<string, unknown>;
851
+ expect(parsed.running).toBe(false);
852
+ });
853
+ });
854
+
855
+ describe("buildCoordinatorBeacon", () => {
856
+ test("is a single line (no newlines)", () => {
857
+ const beacon = buildCoordinatorBeacon();
858
+ expect(beacon).not.toContain("\n");
859
+ });
860
+
861
+ test("includes coordinator identity in header", () => {
862
+ const beacon = buildCoordinatorBeacon();
863
+ expect(beacon).toContain("[LEGIO] coordinator (coordinator)");
864
+ });
865
+
866
+ test("includes ISO timestamp", () => {
867
+ const beacon = buildCoordinatorBeacon();
868
+ expect(beacon).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
869
+ });
870
+
871
+ test("includes depth and parent info", () => {
872
+ const beacon = buildCoordinatorBeacon();
873
+ expect(beacon).toContain("Depth: 0 | Parent: none");
874
+ });
875
+
876
+ test("includes persistent orchestrator role", () => {
877
+ const beacon = buildCoordinatorBeacon();
878
+ expect(beacon).toContain("Role: persistent orchestrator");
879
+ });
880
+
881
+ test("includes startup instructions", () => {
882
+ const beacon = buildCoordinatorBeacon();
883
+ expect(beacon).toContain("mulch prime");
884
+ expect(beacon).toContain("legio mail check --agent coordinator");
885
+ expect(beacon).toContain("bd ready");
886
+ expect(beacon).toContain("legio group status");
887
+ });
888
+
889
+ test("includes hierarchy enforcement instruction", () => {
890
+ const beacon = buildCoordinatorBeacon();
891
+ expect(beacon).toContain("ONLY spawn leads");
892
+ expect(beacon).toContain("NEVER spawn non-lead agents directly");
893
+ });
894
+
895
+ test("includes delegation instruction", () => {
896
+ const beacon = buildCoordinatorBeacon();
897
+ expect(beacon).toContain("DELEGATION");
898
+ expect(beacon).toContain("spawn a lead who will spawn scouts");
899
+ });
900
+
901
+ test("parts are joined with em-dash separator", () => {
902
+ const beacon = buildCoordinatorBeacon();
903
+ // Should have exactly 4 " — " separators (5 parts)
904
+ const dashes = beacon.split(" — ");
905
+ expect(dashes).toHaveLength(5);
906
+ });
907
+ });
908
+
909
+ describe("resolveAttach", () => {
910
+ test("--attach flag forces attach regardless of TTY", () => {
911
+ expect(resolveAttach(["--attach"], false)).toBe(true);
912
+ expect(resolveAttach(["--attach"], true)).toBe(true);
913
+ });
914
+
915
+ test("--no-attach flag forces no attach regardless of TTY", () => {
916
+ expect(resolveAttach(["--no-attach"], false)).toBe(false);
917
+ expect(resolveAttach(["--no-attach"], true)).toBe(false);
918
+ });
919
+
920
+ test("--attach takes precedence when both flags are present", () => {
921
+ expect(resolveAttach(["--attach", "--no-attach"], false)).toBe(true);
922
+ expect(resolveAttach(["--attach", "--no-attach"], true)).toBe(true);
923
+ });
924
+
925
+ test("defaults to TTY state when no flag is set", () => {
926
+ expect(resolveAttach([], true)).toBe(true);
927
+ expect(resolveAttach([], false)).toBe(false);
928
+ });
929
+
930
+ test("works with other flags present", () => {
931
+ expect(resolveAttach(["--json", "--attach"], false)).toBe(true);
932
+ expect(resolveAttach(["--json", "--no-attach"], true)).toBe(false);
933
+ expect(resolveAttach(["--json"], true)).toBe(true);
934
+ });
935
+ });
936
+
937
+ describe("watchdog integration", () => {
938
+ describe("startCoordinator with --watchdog", () => {
939
+ test("calls watchdog.start() when --watchdog flag is present", async () => {
940
+ const { deps, watchdogCalls } = makeDeps({}, { startSuccess: true });
941
+ await captureStdout(() => coordinatorCommand(["start", "--watchdog", "--json"], deps));
942
+
943
+ expect(watchdogCalls?.start).toBe(1);
944
+ });
945
+
946
+ test("does NOT call watchdog.start() when --watchdog flag is absent", async () => {
947
+ const { deps, watchdogCalls } = makeDeps({}, { startSuccess: true });
948
+ await captureStdout(() => coordinatorCommand(["start", "--json"], deps));
949
+
950
+ expect(watchdogCalls?.start).toBe(0);
951
+ });
952
+
953
+ test("--json output includes watchdog:false (output written before watchdog starts)", async () => {
954
+ const { deps } = makeDeps({}, { startSuccess: true });
955
+ const output = await captureStdout(() =>
956
+ coordinatorCommand(["start", "--watchdog", "--json"], deps),
957
+ );
958
+
959
+ const parsed = JSON.parse(output) as Record<string, unknown>;
960
+ expect(parsed.watchdog).toBe(false);
961
+ });
962
+
963
+ test("--json output includes watchdog:false when --watchdog is present but start fails", async () => {
964
+ const { deps } = makeDeps({}, { startSuccess: false });
965
+ const output = await captureStdout(() =>
966
+ coordinatorCommand(["start", "--watchdog", "--json"], deps),
967
+ );
968
+
969
+ const parsed = JSON.parse(output) as Record<string, unknown>;
970
+ expect(parsed.watchdog).toBe(false);
971
+ });
972
+
973
+ test("--json output includes watchdog:false when --watchdog is absent", async () => {
974
+ const { deps } = makeDeps({}, { startSuccess: true });
975
+ const output = await captureStdout(() => coordinatorCommand(["start", "--json"], deps));
976
+
977
+ const parsed = JSON.parse(output) as Record<string, unknown>;
978
+ expect(parsed.watchdog).toBe(false);
979
+ });
980
+
981
+ test("text output includes watchdog PID when --watchdog succeeds", async () => {
982
+ const { deps } = makeDeps({}, { startSuccess: true });
983
+ const output = await captureStdout(() =>
984
+ coordinatorCommand(["start", "--watchdog", "--no-attach"], deps),
985
+ );
986
+
987
+ expect(output).toContain("Watchdog: started (PID 88888)");
988
+ });
989
+ });
990
+
991
+ describe("stopCoordinator watchdog cleanup", () => {
992
+ test("always calls watchdog.stop() when stopping coordinator", async () => {
993
+ const session = makeCoordinatorSession({ state: "working" });
994
+ saveSessionsToDb([session]);
995
+ const { deps, watchdogCalls } = makeDeps(
996
+ { "legio-test-project-coordinator": true },
997
+ { stopSuccess: true },
998
+ );
999
+
1000
+ await captureStdout(() => coordinatorCommand(["stop"], deps));
1001
+
1002
+ expect(watchdogCalls?.stop).toBe(1);
1003
+ });
1004
+
1005
+ test("--json output includes watchdogStopped:true when watchdog was running", async () => {
1006
+ const session = makeCoordinatorSession({ state: "working" });
1007
+ saveSessionsToDb([session]);
1008
+ const { deps } = makeDeps({ "legio-test-project-coordinator": true }, { stopSuccess: true });
1009
+
1010
+ const output = await captureStdout(() => coordinatorCommand(["stop", "--json"], deps));
1011
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1012
+ expect(parsed.watchdogStopped).toBe(true);
1013
+ });
1014
+
1015
+ test("--json output includes watchdogStopped:false when no watchdog was running", async () => {
1016
+ const session = makeCoordinatorSession({ state: "working" });
1017
+ saveSessionsToDb([session]);
1018
+ const { deps } = makeDeps({ "legio-test-project-coordinator": true }, { stopSuccess: false });
1019
+
1020
+ const output = await captureStdout(() => coordinatorCommand(["stop", "--json"], deps));
1021
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1022
+ expect(parsed.watchdogStopped).toBe(false);
1023
+ });
1024
+
1025
+ test("text output shows 'Watchdog stopped' when watchdog was running", async () => {
1026
+ const session = makeCoordinatorSession({ state: "working" });
1027
+ saveSessionsToDb([session]);
1028
+ const { deps } = makeDeps({ "legio-test-project-coordinator": true }, { stopSuccess: true });
1029
+
1030
+ const output = await captureStdout(() => coordinatorCommand(["stop"], deps));
1031
+ expect(output).toContain("Watchdog stopped");
1032
+ });
1033
+
1034
+ test("text output shows 'No watchdog running' when no watchdog was running", async () => {
1035
+ const session = makeCoordinatorSession({ state: "working" });
1036
+ saveSessionsToDb([session]);
1037
+ const { deps } = makeDeps({ "legio-test-project-coordinator": true }, { stopSuccess: false });
1038
+
1039
+ const output = await captureStdout(() => coordinatorCommand(["stop"], deps));
1040
+ expect(output).toContain("No watchdog running");
1041
+ });
1042
+ });
1043
+
1044
+ describe("statusCoordinator watchdog state", () => {
1045
+ test("includes watchdogRunning in JSON output when coordinator is running", async () => {
1046
+ const session = makeCoordinatorSession({ state: "working" });
1047
+ saveSessionsToDb([session]);
1048
+ const { deps } = makeDeps({ "legio-test-project-coordinator": true }, { running: true });
1049
+
1050
+ const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
1051
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1052
+ expect(parsed.watchdogRunning).toBe(true);
1053
+ });
1054
+
1055
+ test("includes watchdogRunning:false in JSON output when watchdog is not running", async () => {
1056
+ const session = makeCoordinatorSession({ state: "working" });
1057
+ saveSessionsToDb([session]);
1058
+ const { deps } = makeDeps({ "legio-test-project-coordinator": true }, { running: false });
1059
+
1060
+ const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
1061
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1062
+ expect(parsed.watchdogRunning).toBe(false);
1063
+ });
1064
+
1065
+ test("text output shows watchdog status when coordinator is running", async () => {
1066
+ const session = makeCoordinatorSession({ state: "working" });
1067
+ saveSessionsToDb([session]);
1068
+ const { deps } = makeDeps({ "legio-test-project-coordinator": true }, { running: true });
1069
+
1070
+ const output = await captureStdout(() => coordinatorCommand(["status"], deps));
1071
+ expect(output).toContain("Watchdog: running");
1072
+ });
1073
+
1074
+ test("text output shows 'not running' when watchdog is not running", async () => {
1075
+ const session = makeCoordinatorSession({ state: "working" });
1076
+ saveSessionsToDb([session]);
1077
+ const { deps } = makeDeps({ "legio-test-project-coordinator": true }, { running: false });
1078
+
1079
+ const output = await captureStdout(() => coordinatorCommand(["status"], deps));
1080
+ expect(output).toContain("Watchdog: not running");
1081
+ });
1082
+
1083
+ test("includes watchdogRunning in JSON output when coordinator is not running", async () => {
1084
+ const { deps } = makeDeps({}, { running: true });
1085
+
1086
+ const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
1087
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1088
+ expect(parsed.running).toBe(false);
1089
+ expect(parsed.watchdogRunning).toBe(true);
1090
+ });
1091
+ });
1092
+
1093
+ describe("COORDINATOR_HELP", () => {
1094
+ test("help text includes --watchdog flag", async () => {
1095
+ const output = await captureStdout(() => coordinatorCommand(["--help"]));
1096
+ expect(output).toContain("--watchdog");
1097
+ expect(output).toContain("Auto-start watchdog daemon with coordinator");
1098
+ });
1099
+ });
1100
+ });
1101
+
1102
+ describe("monitor integration", () => {
1103
+ describe("startCoordinator with --monitor", () => {
1104
+ test("calls monitor.start() when --monitor flag is present", async () => {
1105
+ const { deps, monitorCalls } = makeDeps({}, undefined, { startSuccess: true });
1106
+ await captureStdout(() => coordinatorCommand(["start", "--monitor", "--json"], deps));
1107
+
1108
+ expect(monitorCalls?.start).toBe(1);
1109
+ });
1110
+
1111
+ test("does NOT call monitor.start() when --monitor flag is absent", async () => {
1112
+ const { deps, monitorCalls } = makeDeps({}, undefined, { startSuccess: true });
1113
+ await captureStdout(() => coordinatorCommand(["start", "--json"], deps));
1114
+
1115
+ expect(monitorCalls?.start).toBe(0);
1116
+ });
1117
+
1118
+ test("--json output includes monitor:false (output written before monitor starts)", async () => {
1119
+ const { deps } = makeDeps({}, undefined, { startSuccess: true });
1120
+ const output = await captureStdout(() =>
1121
+ coordinatorCommand(["start", "--monitor", "--json"], deps),
1122
+ );
1123
+
1124
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1125
+ expect(parsed.monitor).toBe(false);
1126
+ });
1127
+
1128
+ test("--json output includes monitor:false when --monitor is present but start fails", async () => {
1129
+ const { deps } = makeDeps({}, undefined, { startSuccess: false });
1130
+ const output = await captureStdout(() =>
1131
+ coordinatorCommand(["start", "--monitor", "--json"], deps),
1132
+ );
1133
+
1134
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1135
+ expect(parsed.monitor).toBe(false);
1136
+ });
1137
+
1138
+ test("--json output includes monitor:false when --monitor is absent", async () => {
1139
+ const { deps } = makeDeps({}, undefined, { startSuccess: true });
1140
+ const output = await captureStdout(() => coordinatorCommand(["start", "--json"], deps));
1141
+
1142
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1143
+ expect(parsed.monitor).toBe(false);
1144
+ });
1145
+
1146
+ test("text output includes monitor PID when --monitor succeeds", async () => {
1147
+ const { deps } = makeDeps({}, undefined, { startSuccess: true });
1148
+ const output = await captureStdout(() =>
1149
+ coordinatorCommand(["start", "--monitor", "--no-attach"], deps),
1150
+ );
1151
+
1152
+ expect(output).toContain("Monitor: started (PID 77777)");
1153
+ });
1154
+ });
1155
+
1156
+ describe("stopCoordinator monitor cleanup", () => {
1157
+ test("always calls monitor.stop() when stopping coordinator", async () => {
1158
+ const session = makeCoordinatorSession({ state: "working" });
1159
+ saveSessionsToDb([session]);
1160
+ const { deps, monitorCalls } = makeDeps(
1161
+ { "legio-test-project-coordinator": true },
1162
+ undefined,
1163
+ { stopSuccess: true },
1164
+ );
1165
+
1166
+ await captureStdout(() => coordinatorCommand(["stop"], deps));
1167
+
1168
+ expect(monitorCalls?.stop).toBe(1);
1169
+ });
1170
+
1171
+ test("--json output includes monitorStopped:true when monitor was running", async () => {
1172
+ const session = makeCoordinatorSession({ state: "working" });
1173
+ saveSessionsToDb([session]);
1174
+ const { deps } = makeDeps({ "legio-test-project-coordinator": true }, undefined, {
1175
+ stopSuccess: true,
1176
+ });
1177
+
1178
+ const output = await captureStdout(() => coordinatorCommand(["stop", "--json"], deps));
1179
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1180
+ expect(parsed.monitorStopped).toBe(true);
1181
+ });
1182
+
1183
+ test("--json output includes monitorStopped:false when no monitor was running", async () => {
1184
+ const session = makeCoordinatorSession({ state: "working" });
1185
+ saveSessionsToDb([session]);
1186
+ const { deps } = makeDeps({ "legio-test-project-coordinator": true }, undefined, {
1187
+ stopSuccess: false,
1188
+ });
1189
+
1190
+ const output = await captureStdout(() => coordinatorCommand(["stop", "--json"], deps));
1191
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1192
+ expect(parsed.monitorStopped).toBe(false);
1193
+ });
1194
+
1195
+ test("text output shows 'Monitor stopped' when monitor was running", async () => {
1196
+ const session = makeCoordinatorSession({ state: "working" });
1197
+ saveSessionsToDb([session]);
1198
+ const { deps } = makeDeps({ "legio-test-project-coordinator": true }, undefined, {
1199
+ stopSuccess: true,
1200
+ });
1201
+
1202
+ const output = await captureStdout(() => coordinatorCommand(["stop"], deps));
1203
+ expect(output).toContain("Monitor stopped");
1204
+ });
1205
+
1206
+ test("text output shows 'No monitor running' when no monitor was running", async () => {
1207
+ const session = makeCoordinatorSession({ state: "working" });
1208
+ saveSessionsToDb([session]);
1209
+ const { deps } = makeDeps({ "legio-test-project-coordinator": true }, undefined, {
1210
+ stopSuccess: false,
1211
+ });
1212
+
1213
+ const output = await captureStdout(() => coordinatorCommand(["stop"], deps));
1214
+ expect(output).toContain("No monitor running");
1215
+ });
1216
+ });
1217
+
1218
+ describe("statusCoordinator monitor state", () => {
1219
+ test("includes monitorRunning in JSON output when coordinator is running", async () => {
1220
+ const session = makeCoordinatorSession({ state: "working" });
1221
+ saveSessionsToDb([session]);
1222
+ const { deps } = makeDeps({ "legio-test-project-coordinator": true }, undefined, {
1223
+ running: true,
1224
+ });
1225
+
1226
+ const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
1227
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1228
+ expect(parsed.monitorRunning).toBe(true);
1229
+ });
1230
+
1231
+ test("includes monitorRunning:false in JSON output when monitor is not running", async () => {
1232
+ const session = makeCoordinatorSession({ state: "working" });
1233
+ saveSessionsToDb([session]);
1234
+ const { deps } = makeDeps({ "legio-test-project-coordinator": true }, undefined, {
1235
+ running: false,
1236
+ });
1237
+
1238
+ const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
1239
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1240
+ expect(parsed.monitorRunning).toBe(false);
1241
+ });
1242
+
1243
+ test("text output shows monitor status when coordinator is running", async () => {
1244
+ const session = makeCoordinatorSession({ state: "working" });
1245
+ saveSessionsToDb([session]);
1246
+ const { deps } = makeDeps({ "legio-test-project-coordinator": true }, undefined, {
1247
+ running: true,
1248
+ });
1249
+
1250
+ const output = await captureStdout(() => coordinatorCommand(["status"], deps));
1251
+ expect(output).toContain("Monitor: running");
1252
+ });
1253
+
1254
+ test("text output shows 'not running' when monitor is not running", async () => {
1255
+ const session = makeCoordinatorSession({ state: "working" });
1256
+ saveSessionsToDb([session]);
1257
+ const { deps } = makeDeps({ "legio-test-project-coordinator": true }, undefined, {
1258
+ running: false,
1259
+ });
1260
+
1261
+ const output = await captureStdout(() => coordinatorCommand(["status"], deps));
1262
+ expect(output).toContain("Monitor: not running");
1263
+ });
1264
+
1265
+ test("includes monitorRunning in JSON output when coordinator is not running", async () => {
1266
+ const { deps } = makeDeps({}, undefined, { running: true });
1267
+
1268
+ const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
1269
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1270
+ expect(parsed.running).toBe(false);
1271
+ expect(parsed.monitorRunning).toBe(true);
1272
+ });
1273
+ });
1274
+
1275
+ describe("COORDINATOR_HELP", () => {
1276
+ test("help text includes --monitor flag", async () => {
1277
+ const output = await captureStdout(() => coordinatorCommand(["--help"]));
1278
+ expect(output).toContain("--monitor");
1279
+ expect(output).toContain("Auto-start monitor agent (Tier 2) with coordinator");
1280
+ });
1281
+ });
1282
+ });
1283
+
1284
+ // ============================================================================
1285
+ // Headless coordinator integration
1286
+ // ============================================================================
1287
+
1288
+ /** A fake HeadlessCoordinatorHandle that emits 'exit' after a short delay. */
1289
+ function makeFakeHeadlessHandle(pid = 55555) {
1290
+ const { EventEmitter } = require("node:events") as typeof import("node:events");
1291
+ const emitter = new EventEmitter();
1292
+ let running = false;
1293
+ let exitTimeout: ReturnType<typeof setTimeout> | null = null;
1294
+
1295
+ const handle = {
1296
+ start() {
1297
+ running = true;
1298
+ // Use setTimeout (not nextTick) so the exit fires AFTER all async
1299
+ // operations in startCoordinator complete and the listener is registered.
1300
+ exitTimeout = setTimeout(() => {
1301
+ running = false;
1302
+ emitter.emit("exit", 0);
1303
+ }, 50);
1304
+ },
1305
+ write(_input: string) {},
1306
+ async stop() {
1307
+ if (exitTimeout !== null) clearTimeout(exitTimeout);
1308
+ running = false;
1309
+ },
1310
+ getPid() {
1311
+ return pid;
1312
+ },
1313
+ isRunning() {
1314
+ return running;
1315
+ },
1316
+ on(event: "exit", listener: (code: number) => void) {
1317
+ emitter.on(event, (...args: unknown[]) => {
1318
+ listener(args[0] as number);
1319
+ });
1320
+ return handle;
1321
+ },
1322
+ };
1323
+
1324
+ return handle;
1325
+ }
1326
+
1327
+ describe("headless coordinator", () => {
1328
+ describe("startCoordinator --headless", () => {
1329
+ test("records session with tmuxSession='headless'", async () => {
1330
+ const fakeHandle = makeFakeHeadlessHandle(55555);
1331
+ const deps: CoordinatorDeps = {
1332
+ ...makeDeps().deps,
1333
+ _headless: { create: () => fakeHandle },
1334
+ _sleep: () => Promise.resolve(),
1335
+ };
1336
+
1337
+ await captureStdout(() => coordinatorCommand(["start", "--headless"], deps));
1338
+
1339
+ const sessions = loadSessionsFromDb();
1340
+ expect(sessions).toHaveLength(1);
1341
+ const session = sessions[0];
1342
+ expect(session?.tmuxSession).toBe("headless");
1343
+ expect(session?.agentName).toBe("coordinator");
1344
+ expect(session?.capability).toBe("coordinator");
1345
+ });
1346
+
1347
+ test("writes headless-coordinator.pid file", async () => {
1348
+ const fakeHandle = makeFakeHeadlessHandle(55555);
1349
+ const deps: CoordinatorDeps = {
1350
+ ...makeDeps().deps,
1351
+ _headless: { create: () => fakeHandle },
1352
+ _sleep: () => Promise.resolve(),
1353
+ };
1354
+
1355
+ await captureStdout(() => coordinatorCommand(["start", "--headless"], deps));
1356
+
1357
+ // PID file is written before the process exits
1358
+ // fakeHandle emits exit on nextTick, so PID file may or may not exist
1359
+ // depending on timing. Check that the session was recorded (more reliable).
1360
+ const sessions = loadSessionsFromDb();
1361
+ expect(sessions[0]?.tmuxSession).toBe("headless");
1362
+ });
1363
+
1364
+ test("--json output includes headless:true", async () => {
1365
+ const fakeHandle = makeFakeHeadlessHandle(55555);
1366
+ const deps: CoordinatorDeps = {
1367
+ ...makeDeps().deps,
1368
+ _headless: { create: () => fakeHandle },
1369
+ _sleep: () => Promise.resolve(),
1370
+ };
1371
+
1372
+ const output = await captureStdout(() =>
1373
+ coordinatorCommand(["start", "--headless", "--json"], deps),
1374
+ );
1375
+
1376
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1377
+ expect(parsed.headless).toBe(true);
1378
+ });
1379
+
1380
+ test("does not call tmux.createSession when headless", async () => {
1381
+ const fakeHandle = makeFakeHeadlessHandle(55555);
1382
+ const { deps, calls } = makeDeps();
1383
+ const headlessDeps: CoordinatorDeps = {
1384
+ ...deps,
1385
+ _headless: { create: () => fakeHandle },
1386
+ _sleep: () => Promise.resolve(),
1387
+ };
1388
+
1389
+ await captureStdout(() => coordinatorCommand(["start", "--headless"], headlessDeps));
1390
+
1391
+ expect(calls.createSession).toHaveLength(0);
1392
+ });
1393
+ });
1394
+
1395
+ describe("stopCoordinator with headless session", () => {
1396
+ test("stops headless session without calling tmux.killSession", async () => {
1397
+ // Create a headless session
1398
+ const headlessSession = makeCoordinatorSession({ tmuxSession: "headless", state: "working" });
1399
+ saveSessionsToDb([headlessSession]);
1400
+
1401
+ // Write a PID file for a process that definitely doesn't exist (high PID)
1402
+ await writeFile(join(legioDir, "headless-coordinator.pid"), "999999999");
1403
+
1404
+ const { deps, calls } = makeDeps();
1405
+
1406
+ await captureStdout(() => coordinatorCommand(["stop"], deps));
1407
+
1408
+ // tmux kill should NOT be called for headless sessions
1409
+ expect(calls.killSession).toHaveLength(0);
1410
+
1411
+ // Session should be completed
1412
+ const sessions = loadSessionsFromDb();
1413
+ expect(sessions[0]?.state).toBe("completed");
1414
+ });
1415
+
1416
+ test("removes PID file when stopping headless session", async () => {
1417
+ const headlessSession = makeCoordinatorSession({ tmuxSession: "headless", state: "working" });
1418
+ saveSessionsToDb([headlessSession]);
1419
+
1420
+ const pidFilePath = join(legioDir, "headless-coordinator.pid");
1421
+ await writeFile(pidFilePath, "999999999");
1422
+
1423
+ const { deps } = makeDeps();
1424
+ await captureStdout(() => coordinatorCommand(["stop"], deps));
1425
+
1426
+ // PID file should be cleaned up
1427
+ let pidFileExists = false;
1428
+ try {
1429
+ await import("node:fs/promises").then((fs) => fs.access(pidFilePath));
1430
+ pidFileExists = true;
1431
+ } catch {
1432
+ // expected
1433
+ }
1434
+ expect(pidFileExists).toBe(false);
1435
+ });
1436
+
1437
+ test("--json stop output includes stopped:true for headless", async () => {
1438
+ const headlessSession = makeCoordinatorSession({ tmuxSession: "headless", state: "working" });
1439
+ saveSessionsToDb([headlessSession]);
1440
+ await writeFile(join(legioDir, "headless-coordinator.pid"), "999999999");
1441
+
1442
+ const { deps } = makeDeps();
1443
+ const output = await captureStdout(() => coordinatorCommand(["stop", "--json"], deps));
1444
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1445
+ expect(parsed.stopped).toBe(true);
1446
+ });
1447
+ });
1448
+
1449
+ describe("statusCoordinator with headless session", () => {
1450
+ test("--json includes headless:true for headless sessions", async () => {
1451
+ // Create a headless session but with a non-existent PID (so it's zombie)
1452
+ const headlessSession = makeCoordinatorSession({
1453
+ tmuxSession: "headless",
1454
+ state: "working",
1455
+ pid: 999999999, // Non-existent PID
1456
+ });
1457
+ saveSessionsToDb([headlessSession]);
1458
+
1459
+ const { deps } = makeDeps();
1460
+ const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
1461
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1462
+ expect(parsed.headless).toBe(true);
1463
+ });
1464
+
1465
+ test("--json includes headless:false for tmux sessions", async () => {
1466
+ const tmuxSession = makeCoordinatorSession({ state: "working" });
1467
+ saveSessionsToDb([tmuxSession]);
1468
+ const { deps } = makeDeps({ "legio-test-project-coordinator": true });
1469
+
1470
+ const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
1471
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1472
+ expect(parsed.headless).toBe(false);
1473
+ });
1474
+ });
1475
+
1476
+ describe("COORDINATOR_HELP includes --headless", () => {
1477
+ test("help text includes --headless flag", async () => {
1478
+ const output = await captureStdout(() => coordinatorCommand(["--help"]));
1479
+ expect(output).toContain("--headless");
1480
+ });
1481
+ });
1482
+ });
1483
+
1484
+ describe("SessionStore round-trip", () => {
1485
+ test("returns empty array when no sessions exist", () => {
1486
+ const sessions = loadSessionsFromDb();
1487
+ expect(sessions).toEqual([]);
1488
+ });
1489
+
1490
+ test("save then load round-trips correctly", () => {
1491
+ const original = [makeCoordinatorSession()];
1492
+ saveSessionsToDb(original);
1493
+ const loaded = loadSessionsFromDb();
1494
+
1495
+ expect(loaded).toHaveLength(1);
1496
+ expect(loaded[0]?.agentName).toBe("coordinator");
1497
+ expect(loaded[0]?.capability).toBe("coordinator");
1498
+ });
1499
+
1500
+ test("sessions.db is created after save", () => {
1501
+ saveSessionsToDb([makeCoordinatorSession()]);
1502
+ const dbPath = join(legioDir, "sessions.db");
1503
+ const exists = statSync(dbPath).size > 0;
1504
+ expect(exists).toBe(true);
1505
+ });
1506
+ });
1507
+
1508
+ /**
1509
+ * Root-user guard: coordinatorCommand start must reject when running as root.
1510
+ */
1511
+ describe("coordinatorCommand root guard", () => {
1512
+ test("throws ValidationError with uid field when process.getuid returns 0", async () => {
1513
+ const original = process.getuid;
1514
+ process.getuid = () => 0;
1515
+ try {
1516
+ await expect(coordinatorCommand(["start"])).rejects.toMatchObject({
1517
+ name: "ValidationError",
1518
+ field: "uid",
1519
+ });
1520
+ } finally {
1521
+ process.getuid = original;
1522
+ }
1523
+ });
1524
+ });