@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,339 @@
1
+ /**
2
+ * CLI command: legio status [--json] [--watch]
3
+ *
4
+ * Shows active agents, worktree status, beads summary, mail queue depth,
5
+ * and merge queue state. --watch mode uses polling for live updates.
6
+ */
7
+
8
+ import { access, readFile } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ import { loadConfig } from "../config.ts";
11
+ import { ValidationError } from "../errors.ts";
12
+ import { createMailStore } from "../mail/store.ts";
13
+ import { createMergeQueue } from "../merge/queue.ts";
14
+ import { createMetricsStore } from "../metrics/store.ts";
15
+ import { openSessionStore } from "../sessions/compat.ts";
16
+ import type { AgentSession } from "../types.ts";
17
+ import { listWorktrees } from "../worktree/manager.ts";
18
+ import { listSessions } from "../worktree/tmux.ts";
19
+
20
+ /**
21
+ * Parse a named flag value from args.
22
+ */
23
+ function getFlag(args: string[], flag: string): string | undefined {
24
+ const idx = args.indexOf(flag);
25
+ if (idx === -1 || idx + 1 >= args.length) {
26
+ return undefined;
27
+ }
28
+ return args[idx + 1];
29
+ }
30
+
31
+ function hasFlag(args: string[], flag: string): boolean {
32
+ return args.includes(flag);
33
+ }
34
+
35
+ /**
36
+ * Format a duration in ms to a human-readable string.
37
+ */
38
+ function formatDuration(ms: number): string {
39
+ const seconds = Math.floor(ms / 1000);
40
+ if (seconds < 60) return `${seconds}s`;
41
+ const minutes = Math.floor(seconds / 60);
42
+ const remainSec = seconds % 60;
43
+ if (minutes < 60) return `${minutes}m ${remainSec}s`;
44
+ const hours = Math.floor(minutes / 60);
45
+ const remainMin = minutes % 60;
46
+ return `${hours}h ${remainMin}m`;
47
+ }
48
+
49
+ export interface VerboseAgentDetail {
50
+ worktreePath: string;
51
+ logsDir: string;
52
+ lastMailSent: string | null;
53
+ lastMailReceived: string | null;
54
+ capability: string;
55
+ }
56
+
57
+ export interface StatusData {
58
+ agents: AgentSession[];
59
+ worktrees: Array<{ path: string; branch: string; head: string }>;
60
+ tmuxSessions: Array<{ name: string; pid: number }>;
61
+ unreadMailCount: number;
62
+ mergeQueueCount: number;
63
+ recentMetricsCount: number;
64
+ verboseDetails?: Record<string, VerboseAgentDetail>;
65
+ }
66
+
67
+ /**
68
+ * Gather all status data.
69
+ * @param agentName - Which agent's perspective for unread mail count (default "orchestrator")
70
+ * @param verbose - When true, collect extra per-agent detail (worktree path, logs dir, last mail)
71
+ */
72
+ export async function gatherStatus(
73
+ root: string,
74
+ agentName = "orchestrator",
75
+ verbose = false,
76
+ ): Promise<StatusData> {
77
+ const legioDir = join(root, ".legio");
78
+ const { store } = openSessionStore(legioDir);
79
+
80
+ let sessions: AgentSession[];
81
+ try {
82
+ const currentRunPath = join(legioDir, "current-run.txt");
83
+ let runId: string | undefined;
84
+ try {
85
+ runId = (await readFile(currentRunPath, "utf8")).trim();
86
+ } catch {
87
+ // current-run.txt does not exist
88
+ }
89
+ sessions = runId ? store.getByRunIncludeOrphans(runId) : store.getActive();
90
+
91
+ const worktrees = await listWorktrees(root);
92
+
93
+ let tmuxSessions: Array<{ name: string; pid: number }> = [];
94
+ try {
95
+ tmuxSessions = await listSessions();
96
+ } catch {
97
+ // tmux might not be running
98
+ }
99
+
100
+ // Reconcile agent states: if tmux session is dead but agent state
101
+ // indicates it should be alive, mark it as zombie
102
+ for (const session of sessions) {
103
+ if (session.state === "booting" || session.state === "working") {
104
+ const tmuxAlive = tmuxSessions.some((s) => s.name === session.tmuxSession);
105
+ if (!tmuxAlive) {
106
+ try {
107
+ store.updateState(session.agentName, "zombie");
108
+ session.state = "zombie";
109
+ } catch {
110
+ // Best effort: don't fail status display if update fails
111
+ }
112
+ }
113
+ }
114
+ }
115
+
116
+ let unreadMailCount = 0;
117
+ let mailStore: ReturnType<typeof createMailStore> | null = null;
118
+ try {
119
+ const mailDbPath = join(root, ".legio", "mail.db");
120
+ let mailDbExists = false;
121
+ try {
122
+ await access(mailDbPath);
123
+ mailDbExists = true;
124
+ } catch {
125
+ /* not found */
126
+ }
127
+ if (mailDbExists) {
128
+ mailStore = createMailStore(mailDbPath);
129
+ const unread = mailStore.getAll({ to: agentName, unread: true });
130
+ unreadMailCount = unread.length;
131
+ }
132
+ } catch {
133
+ // mail db might not exist
134
+ }
135
+
136
+ let mergeQueueCount = 0;
137
+ try {
138
+ const queuePath = join(root, ".legio", "merge-queue.db");
139
+ const queue = createMergeQueue(queuePath);
140
+ mergeQueueCount = queue.list("pending").length;
141
+ queue.close();
142
+ } catch {
143
+ // queue might not exist
144
+ }
145
+
146
+ let recentMetricsCount = 0;
147
+ try {
148
+ const metricsDbPath = join(root, ".legio", "metrics.db");
149
+ let metricsDbExists = false;
150
+ try {
151
+ await access(metricsDbPath);
152
+ metricsDbExists = true;
153
+ } catch {
154
+ /* not found */
155
+ }
156
+ if (metricsDbExists) {
157
+ const metricsStore = createMetricsStore(metricsDbPath);
158
+ recentMetricsCount = metricsStore.getRecentSessions(100).length;
159
+ metricsStore.close();
160
+ }
161
+ } catch {
162
+ // metrics db might not exist
163
+ }
164
+
165
+ let verboseDetails: Record<string, VerboseAgentDetail> | undefined;
166
+ if (verbose && sessions.length > 0) {
167
+ verboseDetails = {};
168
+ for (const session of sessions) {
169
+ const logsDir = join(root, ".legio", "logs", session.agentName);
170
+
171
+ let lastMailSent: string | null = null;
172
+ let lastMailReceived: string | null = null;
173
+ if (mailStore) {
174
+ try {
175
+ const sent = mailStore.getAll({ from: session.agentName });
176
+ if (sent.length > 0 && sent[0]) {
177
+ lastMailSent = sent[0].createdAt;
178
+ }
179
+ const received = mailStore.getAll({ to: session.agentName });
180
+ if (received.length > 0 && received[0]) {
181
+ lastMailReceived = received[0].createdAt;
182
+ }
183
+ } catch {
184
+ // Best effort
185
+ }
186
+ }
187
+
188
+ verboseDetails[session.agentName] = {
189
+ worktreePath: session.worktreePath,
190
+ logsDir,
191
+ lastMailSent,
192
+ lastMailReceived,
193
+ capability: session.capability,
194
+ };
195
+ }
196
+ }
197
+
198
+ if (mailStore) {
199
+ mailStore.close();
200
+ }
201
+
202
+ return {
203
+ agents: sessions,
204
+ worktrees,
205
+ tmuxSessions,
206
+ unreadMailCount,
207
+ mergeQueueCount,
208
+ recentMetricsCount,
209
+ verboseDetails,
210
+ };
211
+ } finally {
212
+ store.close();
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Print status in human-readable format.
218
+ */
219
+ export function printStatus(data: StatusData): void {
220
+ const now = Date.now();
221
+ const w = process.stdout.write.bind(process.stdout);
222
+
223
+ w("📊 Legio Status\n");
224
+ w(`${"═".repeat(60)}\n\n`);
225
+
226
+ // Active agents
227
+ const active = data.agents.filter((a) => a.state !== "zombie" && a.state !== "completed");
228
+ w(`🤖 Agents: ${active.length} active\n`);
229
+ if (active.length > 0) {
230
+ for (const agent of active) {
231
+ const endTime =
232
+ agent.state === "completed" || agent.state === "zombie"
233
+ ? new Date(agent.lastActivity).getTime()
234
+ : now;
235
+ const duration = formatDuration(endTime - new Date(agent.startedAt).getTime());
236
+ const tmuxAlive = data.tmuxSessions.some((s) => s.name === agent.tmuxSession);
237
+ const aliveMarker = tmuxAlive ? "●" : "○";
238
+ w(` ${aliveMarker} ${agent.agentName} [${agent.capability}] `);
239
+ w(`${agent.state} | ${agent.beadId} | ${duration}\n`);
240
+
241
+ const detail = data.verboseDetails?.[agent.agentName];
242
+ if (detail) {
243
+ w(` Worktree: ${detail.worktreePath}\n`);
244
+ w(` Logs: ${detail.logsDir}\n`);
245
+ w(` Mail sent: ${detail.lastMailSent ?? "none"}`);
246
+ w(` | received: ${detail.lastMailReceived ?? "none"}\n`);
247
+ }
248
+ }
249
+ } else {
250
+ w(" No active agents\n");
251
+ }
252
+ w("\n");
253
+
254
+ // Worktrees
255
+ const legioWts = data.worktrees.filter((wt) => wt.branch.startsWith("legio/"));
256
+ w(`🌳 Worktrees: ${legioWts.length}\n`);
257
+ for (const wt of legioWts) {
258
+ w(` ${wt.branch}\n`);
259
+ }
260
+ if (legioWts.length === 0) {
261
+ w(" No agent worktrees\n");
262
+ }
263
+ w("\n");
264
+
265
+ // Mail
266
+ w(`📬 Mail: ${data.unreadMailCount} unread\n`);
267
+
268
+ // Merge queue
269
+ w(`🔀 Merge queue: ${data.mergeQueueCount} pending\n`);
270
+
271
+ // Metrics
272
+ w(`📈 Sessions recorded: ${data.recentMetricsCount}\n`);
273
+ }
274
+
275
+ /**
276
+ * Entry point for `legio status [--json] [--watch]`.
277
+ */
278
+ const STATUS_HELP = `legio status — Show all active agents and project state
279
+
280
+ Usage: legio status [--json] [--verbose] [--agent <name>]
281
+
282
+ Options:
283
+ --json Output as JSON
284
+ --verbose Show extra detail per agent (worktree, logs, mail timestamps)
285
+ --agent <name> Show unread mail for this agent (default: orchestrator)
286
+ --watch (deprecated) Use 'legio dashboard' for live monitoring
287
+ --interval <ms> Poll interval for --watch in milliseconds (default: 3000)
288
+ --help, -h Show this help`;
289
+
290
+ export async function statusCommand(args: string[]): Promise<void> {
291
+ if (args.includes("--help") || args.includes("-h")) {
292
+ process.stdout.write(`${STATUS_HELP}\n`);
293
+ return;
294
+ }
295
+
296
+ const json = hasFlag(args, "--json");
297
+ const watch = hasFlag(args, "--watch");
298
+ const verbose = hasFlag(args, "--verbose");
299
+ const intervalStr = getFlag(args, "--interval");
300
+ const interval = intervalStr ? Number.parseInt(intervalStr, 10) : 3000;
301
+
302
+ if (Number.isNaN(interval) || interval < 500) {
303
+ throw new ValidationError("--interval must be a number >= 500 (milliseconds)", {
304
+ field: "interval",
305
+ value: intervalStr,
306
+ });
307
+ }
308
+
309
+ const agentName = getFlag(args, "--agent") ?? "orchestrator";
310
+
311
+ const cwd = process.cwd();
312
+ const config = await loadConfig(cwd);
313
+ const root = config.project.root;
314
+
315
+ if (watch) {
316
+ process.stderr.write(
317
+ "⚠️ --watch is deprecated. Use 'legio dashboard' for live monitoring.\n\n",
318
+ );
319
+ // Polling loop (kept for one release cycle)
320
+ while (true) {
321
+ // Clear screen
322
+ process.stdout.write("\x1b[2J\x1b[H");
323
+ const data = await gatherStatus(root, agentName, verbose);
324
+ if (json) {
325
+ process.stdout.write(`${JSON.stringify(data, null, "\t")}\n`);
326
+ } else {
327
+ printStatus(data);
328
+ }
329
+ await new Promise((resolve) => setTimeout(resolve, interval));
330
+ }
331
+ } else {
332
+ const data = await gatherStatus(root, agentName, verbose);
333
+ if (json) {
334
+ process.stdout.write(`${JSON.stringify(data, null, "\t")}\n`);
335
+ } else {
336
+ printStatus(data);
337
+ }
338
+ }
339
+ }
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Tests for legio stop command.
3
+ *
4
+ * Uses real temp directories, real git repos, and real SQLite session stores.
5
+ * Tmux operations are injected via StopDeps._tmux DI to avoid real tmux calls
6
+ * in CI (real tmux would interfere with developer sessions).
7
+ *
8
+ * WHY DI instead of mock.module: mock.module() in vitest is process-global
9
+ * and leaks across test files. DI keeps mocks scoped to each test invocation.
10
+ */
11
+
12
+ import { mkdir, writeFile } from "node:fs/promises";
13
+ import { join } from "node:path";
14
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
15
+ import { openSessionStore } from "../sessions/compat.ts";
16
+ import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
17
+ import type { AgentSession } from "../types.ts";
18
+ import { type StopDeps, stopCommand } from "./stop.ts";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Fake tmux helper
22
+ // ---------------------------------------------------------------------------
23
+
24
+ interface TmuxCallTracker {
25
+ isSessionAlive: Array<{ name: string; result: boolean }>;
26
+ killSession: Array<{ name: string }>;
27
+ }
28
+
29
+ /** Build a fake tmux DI that tracks calls and reports sessions as alive/dead. */
30
+ function makeFakeTmux(aliveMap: Record<string, boolean> = {}): {
31
+ tmux: NonNullable<StopDeps["_tmux"]>;
32
+ calls: TmuxCallTracker;
33
+ } {
34
+ const calls: TmuxCallTracker = { isSessionAlive: [], killSession: [] };
35
+ const tmux: NonNullable<StopDeps["_tmux"]> = {
36
+ isSessionAlive: async (name: string): Promise<boolean> => {
37
+ const alive = aliveMap[name] ?? false;
38
+ calls.isSessionAlive.push({ name, result: alive });
39
+ return alive;
40
+ },
41
+ killSession: async (name: string): Promise<void> => {
42
+ calls.killSession.push({ name });
43
+ },
44
+ };
45
+ return { tmux, calls };
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Test fixtures
50
+ // ---------------------------------------------------------------------------
51
+
52
+ let tempDir: string;
53
+ let legioDir: string;
54
+ const originalCwd = process.cwd();
55
+
56
+ /** Make a minimal AgentSession for inserting into the store. */
57
+ function makeSession(overrides: Partial<AgentSession> = {}): AgentSession {
58
+ return {
59
+ id: `session-${Date.now()}-${Math.random()}`,
60
+ agentName: "test-builder",
61
+ capability: "builder",
62
+ worktreePath: join(tempDir, ".legio", "worktrees", "test-builder"),
63
+ branchName: "legio/test-builder/legio-abc1",
64
+ beadId: "legio-abc1",
65
+ tmuxSession: "legio-test-project-test-builder",
66
+ state: "working",
67
+ pid: null,
68
+ parentAgent: null,
69
+ depth: 2,
70
+ runId: null,
71
+ startedAt: new Date().toISOString(),
72
+ lastActivity: new Date().toISOString(),
73
+ escalationLevel: 0,
74
+ stalledSince: null,
75
+ ...overrides,
76
+ };
77
+ }
78
+
79
+ /** Capture stdout output during an async call. */
80
+ async function captureStdout(fn: () => Promise<void>): Promise<string> {
81
+ const chunks: string[] = [];
82
+ const orig = process.stdout.write;
83
+ process.stdout.write = ((chunk: unknown) => {
84
+ chunks.push(String(chunk));
85
+ return true;
86
+ }) as typeof process.stdout.write;
87
+ try {
88
+ await fn();
89
+ } finally {
90
+ process.stdout.write = orig;
91
+ }
92
+ return chunks.join("");
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Setup / teardown
97
+ // ---------------------------------------------------------------------------
98
+
99
+ beforeEach(async () => {
100
+ process.chdir(originalCwd);
101
+ tempDir = await createTempGitRepo();
102
+ legioDir = join(tempDir, ".legio");
103
+ await mkdir(legioDir, { recursive: true });
104
+
105
+ // Minimal config.yaml so loadConfig succeeds
106
+ await writeFile(
107
+ join(legioDir, "config.yaml"),
108
+ ["project:", " name: test-project", ` root: ${tempDir}`, " canonicalBranch: main"].join(
109
+ "\n",
110
+ ),
111
+ );
112
+
113
+ process.chdir(tempDir);
114
+ });
115
+
116
+ afterEach(async () => {
117
+ process.chdir(originalCwd);
118
+ await cleanupTempDir(tempDir);
119
+ });
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Tests
123
+ // ---------------------------------------------------------------------------
124
+
125
+ describe("stopCommand — help", () => {
126
+ it("prints help for --help", async () => {
127
+ const { tmux } = makeFakeTmux();
128
+ const out = await captureStdout(async () => {
129
+ await stopCommand(["--help"], { _tmux: tmux, _projectRoot: tempDir });
130
+ });
131
+ expect(out).toContain("legio stop");
132
+ expect(out).toContain("--agent");
133
+ expect(out).toContain("--json");
134
+ });
135
+
136
+ it("prints help for -h", async () => {
137
+ const { tmux } = makeFakeTmux();
138
+ const out = await captureStdout(async () => {
139
+ await stopCommand(["-h"], { _tmux: tmux, _projectRoot: tempDir });
140
+ });
141
+ expect(out).toContain("legio stop");
142
+ });
143
+ });
144
+
145
+ describe("stopCommand — nothing to stop", () => {
146
+ it("prints Nothing to stop when no active sessions", async () => {
147
+ const { tmux, calls } = makeFakeTmux();
148
+ const out = await captureStdout(async () => {
149
+ await stopCommand([], { _tmux: tmux, _projectRoot: tempDir });
150
+ });
151
+ expect(out).toContain("Nothing to stop");
152
+ expect(calls.killSession).toHaveLength(0);
153
+ });
154
+
155
+ it("outputs JSON with nothingToStop=true and empty stopped array", async () => {
156
+ const { tmux } = makeFakeTmux();
157
+ const out = await captureStdout(async () => {
158
+ await stopCommand(["--json"], { _tmux: tmux, _projectRoot: tempDir });
159
+ });
160
+ const parsed = JSON.parse(out.trim()) as { stopped: string[]; nothingToStop: boolean };
161
+ expect(parsed.nothingToStop).toBe(true);
162
+ expect(parsed.stopped).toHaveLength(0);
163
+ });
164
+ });
165
+
166
+ describe("stopCommand — single session", () => {
167
+ it("kills a live tmux session and marks it completed", async () => {
168
+ // Insert a session into the real store
169
+ const { store } = openSessionStore(legioDir);
170
+ const session = makeSession({
171
+ agentName: "my-builder",
172
+ tmuxSession: "legio-test-project-my-builder",
173
+ });
174
+ store.upsert(session);
175
+ store.close();
176
+
177
+ const { tmux, calls } = makeFakeTmux({ "legio-test-project-my-builder": true });
178
+
179
+ const out = await captureStdout(async () => {
180
+ await stopCommand([], { _tmux: tmux, _projectRoot: tempDir });
181
+ });
182
+
183
+ expect(calls.killSession).toHaveLength(1);
184
+ expect(calls.killSession[0]?.name).toBe("legio-test-project-my-builder");
185
+ expect(out).toContain("my-builder");
186
+ expect(out).toContain("Stopped 1 agent");
187
+
188
+ // Verify session is marked completed
189
+ const { store: store2 } = openSessionStore(legioDir);
190
+ const updated = store2.getByName("my-builder");
191
+ store2.close();
192
+ expect(updated?.state).toBe("completed");
193
+ });
194
+
195
+ it("skips killSession when tmux session is already dead", async () => {
196
+ const { store } = openSessionStore(legioDir);
197
+ store.upsert(makeSession({ agentName: "dead-builder", tmuxSession: "legio-dead" }));
198
+ store.close();
199
+
200
+ const { tmux, calls } = makeFakeTmux({ "legio-dead": false }); // dead
201
+
202
+ await captureStdout(async () => {
203
+ await stopCommand([], { _tmux: tmux, _projectRoot: tempDir });
204
+ });
205
+
206
+ expect(calls.isSessionAlive).toHaveLength(1);
207
+ expect(calls.killSession).toHaveLength(0); // skip kill for dead sessions
208
+ });
209
+
210
+ it("outputs JSON with stopped agent name", async () => {
211
+ const { store } = openSessionStore(legioDir);
212
+ store.upsert(makeSession({ agentName: "json-builder", tmuxSession: "legio-json" }));
213
+ store.close();
214
+
215
+ const { tmux } = makeFakeTmux({ "legio-json": true });
216
+ const out = await captureStdout(async () => {
217
+ await stopCommand(["--json"], { _tmux: tmux, _projectRoot: tempDir });
218
+ });
219
+
220
+ const parsed = JSON.parse(out.trim()) as { stopped: string[]; nothingToStop: boolean };
221
+ expect(parsed.nothingToStop).toBe(false);
222
+ expect(parsed.stopped).toContain("json-builder");
223
+ });
224
+ });
225
+
226
+ describe("stopCommand — deepest-first ordering", () => {
227
+ it("kills deeper sessions before shallower ones", async () => {
228
+ const { store } = openSessionStore(legioDir);
229
+ // Insert a lead (depth=1) and a builder (depth=2) — builder should die first
230
+ store.upsert(
231
+ makeSession({
232
+ agentName: "my-lead",
233
+ depth: 1,
234
+ tmuxSession: "legio-lead",
235
+ }),
236
+ );
237
+ store.upsert(
238
+ makeSession({
239
+ agentName: "my-builder",
240
+ depth: 2,
241
+ tmuxSession: "legio-builder",
242
+ }),
243
+ );
244
+ store.close();
245
+
246
+ const killOrder: string[] = [];
247
+ const tmux: NonNullable<StopDeps["_tmux"]> = {
248
+ isSessionAlive: async () => true,
249
+ killSession: async (name: string) => {
250
+ killOrder.push(name);
251
+ },
252
+ };
253
+
254
+ await captureStdout(async () => {
255
+ await stopCommand([], { _tmux: tmux, _projectRoot: tempDir });
256
+ });
257
+
258
+ // Builder (depth 2) should die before lead (depth 1)
259
+ expect(killOrder[0]).toBe("legio-builder");
260
+ expect(killOrder[1]).toBe("legio-lead");
261
+ });
262
+
263
+ it("handles sessions at equal depth in any order", async () => {
264
+ const { store } = openSessionStore(legioDir);
265
+ store.upsert(makeSession({ agentName: "builder-a", depth: 2, tmuxSession: "legio-a" }));
266
+ store.upsert(makeSession({ agentName: "builder-b", depth: 2, tmuxSession: "legio-b" }));
267
+ store.close();
268
+
269
+ const killOrder: string[] = [];
270
+ const tmux: NonNullable<StopDeps["_tmux"]> = {
271
+ isSessionAlive: async () => true,
272
+ killSession: async (name: string) => {
273
+ killOrder.push(name);
274
+ },
275
+ };
276
+
277
+ await captureStdout(async () => {
278
+ await stopCommand([], { _tmux: tmux, _projectRoot: tempDir });
279
+ });
280
+
281
+ // Both should be killed, order within same depth is unspecified
282
+ expect(killOrder).toHaveLength(2);
283
+ expect(killOrder).toContain("legio-a");
284
+ expect(killOrder).toContain("legio-b");
285
+ });
286
+
287
+ it("correctly orders 3 levels deep", async () => {
288
+ const { store } = openSessionStore(legioDir);
289
+ store.upsert(makeSession({ agentName: "coord", depth: 0, tmuxSession: "legio-coord" }));
290
+ store.upsert(makeSession({ agentName: "lead", depth: 1, tmuxSession: "legio-lead" }));
291
+ store.upsert(makeSession({ agentName: "builder", depth: 2, tmuxSession: "legio-builder" }));
292
+ store.close();
293
+
294
+ const killOrder: string[] = [];
295
+ const tmux: NonNullable<StopDeps["_tmux"]> = {
296
+ isSessionAlive: async () => true,
297
+ killSession: async (name: string) => {
298
+ killOrder.push(name);
299
+ },
300
+ };
301
+
302
+ await captureStdout(async () => {
303
+ await stopCommand([], { _tmux: tmux, _projectRoot: tempDir });
304
+ });
305
+
306
+ expect(killOrder[0]).toBe("legio-builder");
307
+ expect(killOrder[1]).toBe("legio-lead");
308
+ expect(killOrder[2]).toBe("legio-coord");
309
+ });
310
+ });
311
+
312
+ describe("stopCommand — --agent filter", () => {
313
+ it("stops only the specified agent when --agent is given", async () => {
314
+ const { store } = openSessionStore(legioDir);
315
+ store.upsert(makeSession({ agentName: "target-agent", tmuxSession: "legio-target" }));
316
+ store.upsert(makeSession({ agentName: "other-agent", tmuxSession: "legio-other" }));
317
+ store.close();
318
+
319
+ const { tmux, calls } = makeFakeTmux({
320
+ "legio-target": true,
321
+ "legio-other": true,
322
+ });
323
+
324
+ const out = await captureStdout(async () => {
325
+ await stopCommand(["--agent", "target-agent"], { _tmux: tmux, _projectRoot: tempDir });
326
+ });
327
+
328
+ // Only target should be killed
329
+ expect(calls.killSession).toHaveLength(1);
330
+ expect(calls.killSession[0]?.name).toBe("legio-target");
331
+ expect(out).toContain("target-agent");
332
+ });
333
+
334
+ it("throws AgentError when --agent specifies a non-existent agent", async () => {
335
+ const { tmux } = makeFakeTmux();
336
+ await expect(
337
+ stopCommand(["--agent", "nonexistent"], { _tmux: tmux, _projectRoot: tempDir }),
338
+ ).rejects.toThrow("No session found for agent 'nonexistent'");
339
+ });
340
+ });
341
+
342
+ describe("stopCommand — completed sessions not stopped again", () => {
343
+ it("does not stop sessions already in completed state", async () => {
344
+ const { store } = openSessionStore(legioDir);
345
+ // Insert a completed session — should not appear in getActive()
346
+ store.upsert(makeSession({ agentName: "done-agent", state: "completed" }));
347
+ store.close();
348
+
349
+ const { tmux, calls } = makeFakeTmux();
350
+ const out = await captureStdout(async () => {
351
+ await stopCommand([], { _tmux: tmux, _projectRoot: tempDir });
352
+ });
353
+
354
+ expect(calls.killSession).toHaveLength(0);
355
+ expect(out).toContain("Nothing to stop");
356
+ });
357
+ });