@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,275 @@
1
+ /**
2
+ * CLI command: legio up
3
+ *
4
+ * Single command to bring up the full legio stack:
5
+ * 1. Check git repo
6
+ * 2. Initialize .legio/ if needed (legio init)
7
+ * 3. Start the server in daemon mode (legio server start --daemon)
8
+ * The server auto-starts the coordinator with watchdog.
9
+ * 4. Start the gateway (legio gateway start --no-attach)
10
+ * 5. Open the browser (unless --no-open)
11
+ *
12
+ * Running legio up when everything is already running is a safe no-op.
13
+ */
14
+
15
+ import { spawn } from "node:child_process";
16
+ import { access, readFile } from "node:fs/promises";
17
+ import { join } from "node:path";
18
+ import { ServerError, ValidationError } from "../errors.ts";
19
+ import { isProcessRunning } from "../watchdog/health.ts";
20
+
21
+ function getFlag(args: string[], flag: string): string | undefined {
22
+ const idx = args.indexOf(flag);
23
+ if (idx === -1 || idx + 1 >= args.length) return undefined;
24
+ return args[idx + 1];
25
+ }
26
+
27
+ function hasFlag(args: string[], flag: string): boolean {
28
+ return args.includes(flag);
29
+ }
30
+
31
+ async function fileExists(path: string): Promise<boolean> {
32
+ try {
33
+ await access(path);
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Run an external command and collect stdout/stderr + exit code.
42
+ */
43
+ async function runCommand(
44
+ cmd: string[],
45
+ opts?: { cwd?: string },
46
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
47
+ const [command, ...args] = cmd;
48
+ if (!command) {
49
+ return { stdout: "", stderr: "Empty command", exitCode: 1 };
50
+ }
51
+ return new Promise((resolve) => {
52
+ const proc = spawn(command, args, {
53
+ cwd: opts?.cwd,
54
+ stdio: ["ignore", "pipe", "pipe"],
55
+ });
56
+ const stdoutChunks: Buffer[] = [];
57
+ const stderrChunks: Buffer[] = [];
58
+ proc.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
59
+ proc.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
60
+ proc.on("close", (code) => {
61
+ resolve({
62
+ stdout: Buffer.concat(stdoutChunks).toString(),
63
+ stderr: Buffer.concat(stderrChunks).toString(),
64
+ exitCode: code ?? 1,
65
+ });
66
+ });
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Read a PID from a PID file (first line). Returns null if not found or invalid.
72
+ */
73
+ async function readPidFromFile(path: string): Promise<number | null> {
74
+ try {
75
+ const text = await readFile(path, "utf-8");
76
+ const firstLine = text.trim().split("\n")[0] ?? "";
77
+ const pid = Number.parseInt(firstLine, 10);
78
+ if (Number.isNaN(pid) || pid <= 0) return null;
79
+ return pid;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Open the browser at the given URL. Fire-and-forget.
87
+ */
88
+ function openBrowser(url: string): void {
89
+ const cmd = process.platform === "darwin" ? "open" : "xdg-open";
90
+ const child = spawn(cmd, [url], { detached: true, stdio: "ignore" });
91
+ child.unref();
92
+ }
93
+
94
+ /** Dependency injection interface for testing. */
95
+ export interface UpDeps {
96
+ _runCommand?: (
97
+ cmd: string[],
98
+ opts?: { cwd?: string },
99
+ ) => Promise<{ stdout: string; stderr: string; exitCode: number }>;
100
+ _fileExists?: (path: string) => Promise<boolean>;
101
+ _readPid?: (path: string) => Promise<number | null>;
102
+ _isProcessRunning?: (pid: number) => boolean;
103
+ _openBrowser?: (url: string) => void;
104
+ _projectRoot?: string;
105
+ }
106
+
107
+ const UP_HELP = `legio up — Start the full legio stack
108
+
109
+ Usage: legio up [options]
110
+
111
+ Options:
112
+ --port <n> Server port (default: 4173)
113
+ --host <addr> Bind address (default: 127.0.0.1)
114
+ --no-open Do not auto-open browser
115
+ --force Force reinitialize .legio/ even if it exists
116
+ --json JSON output
117
+ --help, -h Show this help
118
+
119
+ legio up initializes .legio/ if needed, starts the server in daemon mode,
120
+ starts the gateway, and opens the browser. The server auto-starts the
121
+ coordinator with watchdog. Running legio up when already running is a no-op.`;
122
+
123
+ /**
124
+ * Entry point for \`legio up [options]\`.
125
+ *
126
+ * @param args - CLI arguments after "up"
127
+ * @param deps - Optional dependency injection for testing
128
+ */
129
+ export async function upCommand(args: string[], deps: UpDeps = {}): Promise<void> {
130
+ if (hasFlag(args, "--help") || hasFlag(args, "-h")) {
131
+ process.stdout.write(`${UP_HELP}\n`);
132
+ return;
133
+ }
134
+
135
+ const json = hasFlag(args, "--json");
136
+ const force = hasFlag(args, "--force");
137
+ const noOpen = hasFlag(args, "--no-open");
138
+ const portStr = getFlag(args, "--port");
139
+ const host = getFlag(args, "--host") ?? "127.0.0.1";
140
+ const port = portStr ? Number.parseInt(portStr, 10) : 4173;
141
+
142
+ if (Number.isNaN(port) || port < 1 || port > 65535) {
143
+ throw new ValidationError("--port must be a valid port number (1-65535)", {
144
+ field: "port",
145
+ value: portStr,
146
+ });
147
+ }
148
+
149
+ const run = deps._runCommand ?? runCommand;
150
+ const fileExistsFn = deps._fileExists ?? fileExists;
151
+ const readPidFn = deps._readPid ?? readPidFromFile;
152
+ const isRunningFn = deps._isProcessRunning ?? isProcessRunning;
153
+ const openBrowserFn = deps._openBrowser ?? openBrowser;
154
+ const projectRoot = deps._projectRoot ?? process.cwd();
155
+
156
+ // 1. Check git repo
157
+ const gitCheck = await run(["git", "rev-parse", "--is-inside-work-tree"], { cwd: projectRoot });
158
+ if (gitCheck.exitCode !== 0) {
159
+ throw new ValidationError("legio requires a git repository. Run 'git init' first.", {
160
+ field: "git",
161
+ });
162
+ }
163
+
164
+ let initRan = false;
165
+ let serverStarted = false;
166
+ let serverAlreadyRunning = false;
167
+ let gatewayStarted = false;
168
+ let gatewayAlreadyRunning = false;
169
+
170
+ // 2. Check if .legio/ is initialized
171
+ const configPath = join(projectRoot, ".legio", "config.yaml");
172
+ const initialized = await fileExistsFn(configPath);
173
+
174
+ if (!initialized || force) {
175
+ if (!json) {
176
+ const msg = initialized
177
+ ? "Reinitializing .legio/ (--force)...\n"
178
+ : "Initializing .legio/...\n";
179
+ process.stdout.write(msg);
180
+ }
181
+ const initArgs = ["legio", "init"];
182
+ if (force) initArgs.push("--force");
183
+ const initResult = await run(initArgs, { cwd: projectRoot });
184
+ if (initResult.exitCode !== 0) {
185
+ throw new ValidationError(`Init failed: ${initResult.stderr.trim()}`, {
186
+ field: "init",
187
+ });
188
+ }
189
+ if (!json && initResult.stdout) process.stdout.write(initResult.stdout);
190
+ initRan = true;
191
+ }
192
+
193
+ // 3. Check if server is already running
194
+ const pidFile = join(projectRoot, ".legio", "server.pid");
195
+ const pidFileExists = await fileExistsFn(pidFile);
196
+ let serverPid: number | undefined;
197
+
198
+ if (pidFileExists) {
199
+ const pid = await readPidFn(pidFile);
200
+ if (pid !== null && isRunningFn(pid)) {
201
+ serverAlreadyRunning = true;
202
+ serverPid = pid;
203
+ if (!json) {
204
+ process.stdout.write(`Server already running (PID ${pid})\n`);
205
+ }
206
+ }
207
+ }
208
+
209
+ if (!serverAlreadyRunning) {
210
+ // Start server in daemon mode
211
+ if (!json) {
212
+ process.stdout.write(`Starting server on ${host}:${port}...\n`);
213
+ }
214
+ const serverResult = await run(
215
+ ["legio", "server", "start", "--daemon", "--port", String(port), "--host", host],
216
+ { cwd: projectRoot },
217
+ );
218
+ if (serverResult.exitCode !== 0) {
219
+ throw new ServerError(`Server start failed: ${serverResult.stderr.trim()}`, { port });
220
+ }
221
+ if (!json && serverResult.stdout) process.stdout.write(serverResult.stdout);
222
+ serverStarted = true;
223
+ }
224
+
225
+ // 4. Check if gateway is already running and start if needed (non-fatal)
226
+ let gatewayRunning = false;
227
+ try {
228
+ const gatewayStatus = await run(["legio", "gateway", "status", "--json"], { cwd: projectRoot });
229
+ if (gatewayStatus.exitCode === 0) {
230
+ const statusData = JSON.parse(gatewayStatus.stdout.trim()) as { running?: boolean };
231
+ if (statusData.running === true) {
232
+ gatewayAlreadyRunning = true;
233
+ gatewayRunning = true;
234
+ if (!json) process.stdout.write("Gateway already running\n");
235
+ }
236
+ }
237
+ } catch {
238
+ // ignore status check errors, proceed to try starting
239
+ }
240
+ if (!gatewayRunning) {
241
+ const gatewayStart = await run(["legio", "gateway", "start", "--no-attach"], {
242
+ cwd: projectRoot,
243
+ });
244
+ if (gatewayStart.exitCode === 0) {
245
+ gatewayStarted = true;
246
+ if (!json && gatewayStart.stdout) process.stdout.write(gatewayStart.stdout);
247
+ } else {
248
+ process.stderr.write(`Warning: gateway start failed: ${gatewayStart.stderr.trim()}\n`);
249
+ }
250
+ }
251
+
252
+ const url = `http://${host}:${port}`;
253
+
254
+ // 5. Open browser (unless --no-open)
255
+ if (!noOpen) {
256
+ openBrowserFn(url);
257
+ }
258
+
259
+ // 6. Print summary
260
+ if (json) {
261
+ process.stdout.write(
262
+ `${JSON.stringify({
263
+ url,
264
+ initRan,
265
+ serverStarted,
266
+ serverAlreadyRunning,
267
+ serverPid,
268
+ gatewayStarted,
269
+ gatewayAlreadyRunning,
270
+ })}\n`,
271
+ );
272
+ } else {
273
+ process.stdout.write(`\nLegio is up at ${url}\n`);
274
+ }
275
+ }
@@ -0,0 +1,152 @@
1
+ import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
5
+ import { watchCommand } from "./watch.ts";
6
+
7
+ /**
8
+ * Tests for `legio watch` command.
9
+ *
10
+ * IMPORTANT: We CANNOT test the actual daemon loop (it would hang the test).
11
+ * Focus on:
12
+ * - Help output (safe, returns immediately)
13
+ * - Background mode: already-running detection
14
+ * - Background mode: stale PID cleanup
15
+ *
16
+ * We do NOT test:
17
+ * - Foreground mode (blocks forever with await new Promise(() => {}))
18
+ * - Actual health check loop behavior
19
+ */
20
+
21
+ describe("watchCommand", () => {
22
+ let chunks: string[];
23
+ let stderrChunks: string[];
24
+ let originalWrite: typeof process.stdout.write;
25
+ let originalStderrWrite: typeof process.stderr.write;
26
+ let tempDir: string;
27
+ let originalCwd: string;
28
+ let originalExitCode: string | number | null | undefined;
29
+
30
+ beforeEach(async () => {
31
+ // Spy on stdout
32
+ chunks = [];
33
+ originalWrite = process.stdout.write;
34
+ process.stdout.write = ((chunk: string) => {
35
+ chunks.push(chunk);
36
+ return true;
37
+ }) as typeof process.stdout.write;
38
+
39
+ // Spy on stderr
40
+ stderrChunks = [];
41
+ originalStderrWrite = process.stderr.write;
42
+ process.stderr.write = ((chunk: string) => {
43
+ stderrChunks.push(chunk);
44
+ return true;
45
+ }) as typeof process.stderr.write;
46
+
47
+ // Save original exitCode
48
+ originalExitCode = process.exitCode;
49
+ process.exitCode = 0;
50
+
51
+ // Create temp dir with .legio/config.yaml structure
52
+ tempDir = await mkdtemp(join(tmpdir(), "watch-test-"));
53
+ const legioDir = join(tempDir, ".legio");
54
+ await mkdir(legioDir, { recursive: true });
55
+ await writeFile(
56
+ join(legioDir, "config.yaml"),
57
+ `project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
58
+ );
59
+
60
+ // Change to temp dir so loadConfig() works
61
+ originalCwd = process.cwd();
62
+ process.chdir(tempDir);
63
+ });
64
+
65
+ afterEach(async () => {
66
+ process.stdout.write = originalWrite;
67
+ process.stderr.write = originalStderrWrite;
68
+ process.exitCode = originalExitCode;
69
+ process.chdir(originalCwd);
70
+ await rm(tempDir, { recursive: true, force: true });
71
+ });
72
+
73
+ function output(): string {
74
+ return chunks.join("");
75
+ }
76
+
77
+ function stderr(): string {
78
+ return stderrChunks.join("");
79
+ }
80
+
81
+ test("--help flag shows help text with key info", async () => {
82
+ await watchCommand(["--help"]);
83
+ const out = output();
84
+
85
+ expect(out).toContain("legio watch");
86
+ expect(out).toContain("--interval");
87
+ expect(out).toContain("--background");
88
+ expect(out).toContain("Tier 0");
89
+ });
90
+
91
+ test("-h flag shows help text", async () => {
92
+ await watchCommand(["-h"]);
93
+ const out = output();
94
+
95
+ expect(out).toContain("legio watch");
96
+ expect(out).toContain("Tier 0");
97
+ });
98
+
99
+ test("background mode: already running detection", async () => {
100
+ // Write a PID file with a running process (use our own PID)
101
+ const pidFilePath = join(tempDir, ".legio", "watchdog.pid");
102
+ await writeFile(pidFilePath, `${process.pid}\n`);
103
+
104
+ // Try to start in background mode — should fail with "already running"
105
+ await watchCommand(["--background"]);
106
+
107
+ const err = stderr();
108
+ expect(err).toContain("already running");
109
+ expect(err).toContain(`${process.pid}`);
110
+ expect(process.exitCode).toBe(1);
111
+ });
112
+
113
+ test("background mode: stale PID cleanup", async () => {
114
+ // Write a PID file with a non-running process (999999 is very unlikely to exist)
115
+ const pidFilePath = join(tempDir, ".legio", "watchdog.pid");
116
+ await writeFile(pidFilePath, "999999\n");
117
+
118
+ // Verify the stale PID file exists before the test
119
+ const fileBeforeExists = await access(pidFilePath).then(
120
+ () => true,
121
+ () => false,
122
+ );
123
+ expect(fileBeforeExists).toBe(true);
124
+
125
+ // Try to start in background mode
126
+ // This will clean up the stale PID file, then attempt to spawn.
127
+ // The spawn will fail because there's no real legio binary in test env,
128
+ // but the important part is that the stale PID file gets removed.
129
+ try {
130
+ await watchCommand(["--background"]);
131
+ } catch {
132
+ // Expected to fail when trying to spawn — that's OK
133
+ }
134
+
135
+ // The stale PID file should have been removed during the check
136
+ // (Even if the spawn itself failed, the cleanup happens before spawn)
137
+ // Actually, looking at the code: if existingPid is not null but not running,
138
+ // it removes the PID file. Then it tries to spawn. So the file should be gone
139
+ // OR replaced with a new PID.
140
+
141
+ // Let's check: the file should either not exist, OR contain a different PID
142
+ const fileAfterExists = await access(pidFilePath).then(
143
+ () => true,
144
+ () => false,
145
+ );
146
+ if (fileAfterExists) {
147
+ const content = await readFile(pidFilePath, "utf-8");
148
+ expect(content.trim()).not.toBe("999999");
149
+ }
150
+ // If it doesn't exist, that's also valid (spawn failed before writing new PID)
151
+ });
152
+ });
@@ -0,0 +1,238 @@
1
+ /**
2
+ * CLI command: legio watch [--interval <ms>] [--background]
3
+ *
4
+ * Starts the Tier 0 mechanical watchdog daemon. Foreground mode shows real-time status.
5
+ * Background mode spawns a detached process via node:child_process and writes a PID file.
6
+ * Interval configurable, default 30000ms.
7
+ */
8
+
9
+ import { spawn } from "node:child_process";
10
+ import { readFile, writeFile } from "node:fs/promises";
11
+ import { join } from "node:path";
12
+ import { loadConfig } from "../config.ts";
13
+ import { LegioError } from "../errors.ts";
14
+ import type { HealthCheck } from "../types.ts";
15
+ import { startDaemon } from "../watchdog/daemon.ts";
16
+ import { isProcessRunning } from "../watchdog/health.ts";
17
+
18
+ /**
19
+ * Parse a named flag value from args.
20
+ */
21
+ function getFlag(args: string[], flag: string): string | undefined {
22
+ const idx = args.indexOf(flag);
23
+ if (idx === -1 || idx + 1 >= args.length) {
24
+ return undefined;
25
+ }
26
+ return args[idx + 1];
27
+ }
28
+
29
+ function hasFlag(args: string[], flag: string): boolean {
30
+ return args.includes(flag);
31
+ }
32
+
33
+ /**
34
+ * Format a health check for display.
35
+ */
36
+ function formatCheck(check: HealthCheck): string {
37
+ const actionIcon =
38
+ check.action === "terminate"
39
+ ? "💀"
40
+ : check.action === "escalate"
41
+ ? "⚠️"
42
+ : check.action === "investigate"
43
+ ? "🔍"
44
+ : "✅";
45
+ const pidLabel = check.pidAlive === null ? "n/a" : check.pidAlive ? "up" : "down";
46
+ let line = `${actionIcon} ${check.agentName}: ${check.state} (tmux=${check.tmuxAlive ? "up" : "down"}, pid=${pidLabel})`;
47
+ if (check.reconciliationNote) {
48
+ line += ` [${check.reconciliationNote}]`;
49
+ }
50
+ return line;
51
+ }
52
+
53
+ // isProcessRunning is imported from ../watchdog/health.ts (ZFC shared utility)
54
+
55
+ /**
56
+ * Read the PID from the watchdog PID file.
57
+ * Returns null if the file doesn't exist or can't be parsed.
58
+ */
59
+ async function readPidFile(pidFilePath: string): Promise<number | null> {
60
+ try {
61
+ const text = await readFile(pidFilePath, "utf-8");
62
+ const pid = Number.parseInt(text.trim(), 10);
63
+ if (Number.isNaN(pid) || pid <= 0) {
64
+ return null;
65
+ }
66
+ return pid;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Write a PID to the watchdog PID file.
74
+ */
75
+ async function writePidFile(pidFilePath: string, pid: number): Promise<void> {
76
+ await writeFile(pidFilePath, `${pid}\n`);
77
+ }
78
+
79
+ /**
80
+ * Remove the watchdog PID file.
81
+ */
82
+ async function removePidFile(pidFilePath: string): Promise<void> {
83
+ const { unlink } = await import("node:fs/promises");
84
+ try {
85
+ await unlink(pidFilePath);
86
+ } catch {
87
+ // File may already be gone — not an error
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Resolve the path to the legio binary for re-launching.
93
+ * Uses `which legio` first, then falls back to process.argv.
94
+ */
95
+ async function resolveLegioBin(): Promise<string> {
96
+ try {
97
+ const result = await new Promise<{ exitCode: number; stdout: string }>((resolve) => {
98
+ const proc = spawn("which", ["legio"], { stdio: ["ignore", "pipe", "pipe"] });
99
+ const stdoutChunks: Buffer[] = [];
100
+ proc.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
101
+ proc.on("close", (code: number | null) => {
102
+ resolve({ exitCode: code ?? 1, stdout: Buffer.concat(stdoutChunks).toString("utf-8") });
103
+ });
104
+ });
105
+ if (result.exitCode === 0) {
106
+ const binPath = result.stdout.trim();
107
+ if (binPath.length > 0) {
108
+ return binPath;
109
+ }
110
+ }
111
+ } catch {
112
+ // which not available or legio not on PATH
113
+ }
114
+
115
+ // Fallback: use the script that's currently running (process.argv[1])
116
+ const scriptPath = process.argv[1];
117
+ if (scriptPath) {
118
+ return scriptPath;
119
+ }
120
+
121
+ throw new LegioError("Cannot resolve legio binary path for background launch", "WATCH_ERROR");
122
+ }
123
+
124
+ /**
125
+ * Entry point for `legio watch [--interval <ms>] [--background]`.
126
+ */
127
+ const WATCH_HELP = `legio watch — Start Tier 0 mechanical watchdog daemon
128
+
129
+ Usage: legio watch [--interval <ms>] [--background]
130
+
131
+ Tier numbering:
132
+ Tier 0 Mechanical daemon (heartbeat, tmux/pid liveness) — this command
133
+ Tier 1 Triage agent (ephemeral AI analysis of stalled agents)
134
+ Tier 2 Monitor agent (continuous patrol — not yet implemented)
135
+ Tier 3 Supervisor monitors (per-project)
136
+
137
+ Options:
138
+ --interval <ms> Health check interval in milliseconds (default: from config)
139
+ --background Daemonize (run in background)
140
+ --help, -h Show this help`;
141
+
142
+ export async function watchCommand(args: string[]): Promise<void> {
143
+ if (args.includes("--help") || args.includes("-h")) {
144
+ process.stdout.write(`${WATCH_HELP}\n`);
145
+ return;
146
+ }
147
+
148
+ const intervalStr = getFlag(args, "--interval");
149
+ const background = hasFlag(args, "--background");
150
+
151
+ const cwd = process.cwd();
152
+ const config = await loadConfig(cwd);
153
+
154
+ const intervalMs = intervalStr
155
+ ? Number.parseInt(intervalStr, 10)
156
+ : config.watchdog.tier0IntervalMs;
157
+
158
+ const zombieThresholdMs = config.watchdog.zombieThresholdMs;
159
+ const pidFilePath = join(config.project.root, ".legio", "watchdog.pid");
160
+
161
+ if (background) {
162
+ // Check if a watchdog is already running
163
+ const existingPid = await readPidFile(pidFilePath);
164
+ if (existingPid !== null && isProcessRunning(existingPid)) {
165
+ process.stderr.write(
166
+ `Error: Watchdog already running (PID: ${existingPid}). ` +
167
+ `Kill it first or remove ${pidFilePath}\n`,
168
+ );
169
+ process.exitCode = 1;
170
+ return;
171
+ }
172
+
173
+ // Clean up stale PID file if process is no longer running
174
+ if (existingPid !== null) {
175
+ await removePidFile(pidFilePath);
176
+ }
177
+
178
+ // Build the args for the child process, forwarding --interval but not --background
179
+ const childArgs: string[] = ["watch"];
180
+ if (intervalStr) {
181
+ childArgs.push("--interval", intervalStr);
182
+ }
183
+
184
+ // Resolve the legio binary path
185
+ const legioBin = await resolveLegioBin();
186
+
187
+ // Spawn a detached background process running `legio watch` (without --background)
188
+ const child = spawn(process.execPath, ["--import", "tsx", legioBin, ...childArgs], {
189
+ cwd,
190
+ stdio: "ignore",
191
+ detached: true,
192
+ });
193
+
194
+ // Unref the child so the parent can exit without waiting for it
195
+ child.unref();
196
+
197
+ const childPid = child.pid ?? 0;
198
+
199
+ // Write PID file for later cleanup
200
+ await writePidFile(pidFilePath, childPid);
201
+
202
+ process.stdout.write(
203
+ `Watchdog started in background (PID: ${childPid}, interval: ${intervalMs}ms)\n`,
204
+ );
205
+ process.stdout.write(`PID file: ${pidFilePath}\n`);
206
+ return;
207
+ }
208
+
209
+ // Foreground mode: show real-time health checks
210
+ process.stdout.write(`Watchdog running (interval: ${intervalMs}ms)\n`);
211
+ process.stdout.write("Press Ctrl+C to stop.\n\n");
212
+
213
+ // Write PID file so `--background` check and external tools can find us
214
+ await writePidFile(pidFilePath, process.pid);
215
+
216
+ const { stop } = startDaemon({
217
+ root: config.project.root,
218
+ intervalMs,
219
+ zombieThresholdMs,
220
+ onHealthCheck(check) {
221
+ const timestamp = new Date().toISOString().slice(11, 19);
222
+ process.stdout.write(`[${timestamp}] ${formatCheck(check)}\n`);
223
+ },
224
+ });
225
+
226
+ // Keep running until interrupted
227
+ process.on("SIGINT", () => {
228
+ stop();
229
+ // Clean up PID file on graceful shutdown
230
+ removePidFile(pidFilePath).finally(() => {
231
+ process.stdout.write("\nWatchdog stopped.\n");
232
+ process.exit(0);
233
+ });
234
+ });
235
+
236
+ // Block forever
237
+ await new Promise(() => {});
238
+ }