@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,698 @@
1
+ /**
2
+ * Tests for legio gateway command.
3
+ *
4
+ * Uses real temp directories and real git repos for file I/O and config loading.
5
+ * Tmux is injected via the GatewayDeps 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 coordinator.ts
11
+ * _tmux/_sleep) ensures mocks are scoped to each test invocation.
12
+ */
13
+
14
+ import { access, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
15
+ import { join } from "node:path";
16
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
17
+ import { AgentError, ValidationError } from "../errors.ts";
18
+ import { openSessionStore } from "../sessions/compat.ts";
19
+ import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
20
+ import type { AgentSession } from "../types.ts";
21
+ import { buildGatewayBeacon, type GatewayDeps, gatewayCommand, resolveAttach } from "./gateway.ts";
22
+
23
+ // --- Fake Tmux ---
24
+
25
+ /** Track calls to fake tmux for assertions. */
26
+ interface TmuxCallTracker {
27
+ createSession: Array<{
28
+ name: string;
29
+ cwd: string;
30
+ command: string;
31
+ env?: Record<string, string>;
32
+ }>;
33
+ isSessionAlive: Array<{ name: string; result: boolean }>;
34
+ killSession: Array<{ name: string }>;
35
+ sendKeys: Array<{ name: string; keys: string }>;
36
+ }
37
+
38
+ /** Build a fake tmux DI object with configurable session liveness. */
39
+ function makeFakeTmux(sessionAliveMap: Record<string, boolean> = {}): {
40
+ tmux: NonNullable<GatewayDeps["_tmux"]>;
41
+ calls: TmuxCallTracker;
42
+ } {
43
+ const calls: TmuxCallTracker = {
44
+ createSession: [],
45
+ isSessionAlive: [],
46
+ killSession: [],
47
+ sendKeys: [],
48
+ };
49
+
50
+ const tmux: NonNullable<GatewayDeps["_tmux"]> = {
51
+ createSession: async (
52
+ name: string,
53
+ cwd: string,
54
+ command: string,
55
+ env?: Record<string, string>,
56
+ ): Promise<number> => {
57
+ calls.createSession.push({ name, cwd, command, env });
58
+ return 99999; // Fake PID
59
+ },
60
+ isSessionAlive: async (name: string): Promise<boolean> => {
61
+ const alive = sessionAliveMap[name] ?? false;
62
+ calls.isSessionAlive.push({ name, result: alive });
63
+ return alive;
64
+ },
65
+ killSession: async (name: string): Promise<void> => {
66
+ calls.killSession.push({ name });
67
+ },
68
+ sendKeys: async (name: string, keys: string): Promise<void> => {
69
+ calls.sendKeys.push({ name, keys });
70
+ },
71
+ };
72
+
73
+ return { tmux, calls };
74
+ }
75
+
76
+ // --- Test Setup ---
77
+
78
+ let tempDir: string;
79
+ let legioDir: string;
80
+ const originalCwd = process.cwd();
81
+
82
+ /** Save sessions to the SessionStore (sessions.db) for test setup. */
83
+ function saveSessionsToDb(sessions: AgentSession[]): void {
84
+ const { store } = openSessionStore(legioDir);
85
+ try {
86
+ for (const session of sessions) {
87
+ store.upsert(session);
88
+ }
89
+ } finally {
90
+ store.close();
91
+ }
92
+ }
93
+
94
+ /** Load all sessions from the SessionStore (sessions.db). */
95
+ function loadSessionsFromDb(): AgentSession[] {
96
+ const { store } = openSessionStore(legioDir);
97
+ try {
98
+ return store.getAll();
99
+ } finally {
100
+ store.close();
101
+ }
102
+ }
103
+
104
+ beforeEach(async () => {
105
+ // Restore cwd FIRST so createTempGitRepo's git operations don't fail
106
+ // if a prior test's tempDir was already cleaned up.
107
+ process.chdir(originalCwd);
108
+
109
+ tempDir = await realpath(await createTempGitRepo());
110
+ legioDir = join(tempDir, ".legio");
111
+ await mkdir(legioDir, { recursive: true });
112
+
113
+ // Write a minimal config.yaml so loadConfig succeeds
114
+ await writeFile(
115
+ join(legioDir, "config.yaml"),
116
+ ["project:", " name: test-project", ` root: ${tempDir}`, " canonicalBranch: main"].join(
117
+ "\n",
118
+ ),
119
+ );
120
+
121
+ // Write agent-manifest.json and stub agent-def .md files so manifest loading succeeds
122
+ const agentDefsDir = join(legioDir, "agent-defs");
123
+ await mkdir(agentDefsDir, { recursive: true });
124
+ const manifest = {
125
+ version: "1.0",
126
+ agents: {
127
+ gateway: {
128
+ file: "gateway.md",
129
+ model: "sonnet",
130
+ tools: ["Read", "Bash"],
131
+ capabilities: ["plan"],
132
+ canSpawn: false,
133
+ constraints: [],
134
+ },
135
+ },
136
+ capabilityIndex: { plan: ["gateway"] },
137
+ };
138
+ await writeFile(
139
+ join(legioDir, "agent-manifest.json"),
140
+ `${JSON.stringify(manifest, null, "\t")}\n`,
141
+ );
142
+ await writeFile(join(agentDefsDir, "gateway.md"), "# Gateway\n");
143
+
144
+ // Override cwd so gateway commands find our temp project
145
+ process.chdir(tempDir);
146
+ }, 30000); // 30s timeout: createTempGitRepo can be slow on first run
147
+
148
+ afterEach(async () => {
149
+ process.chdir(originalCwd);
150
+ await cleanupTempDir(tempDir);
151
+ });
152
+
153
+ // --- Helpers ---
154
+
155
+ function makeGatewaySession(overrides: Partial<AgentSession> = {}): AgentSession {
156
+ return {
157
+ id: `session-${Date.now()}-gateway`,
158
+ agentName: "gateway",
159
+ capability: "gateway",
160
+ worktreePath: tempDir,
161
+ branchName: "main",
162
+ beadId: "",
163
+ tmuxSession: "legio-test-project-gateway",
164
+ state: "working",
165
+ pid: 99999,
166
+ parentAgent: null,
167
+ depth: 0,
168
+ runId: null,
169
+ startedAt: new Date().toISOString(),
170
+ lastActivity: new Date().toISOString(),
171
+ escalationLevel: 0,
172
+ stalledSince: null,
173
+ ...overrides,
174
+ };
175
+ }
176
+
177
+ /** Capture stdout.write output during a function call. */
178
+ async function captureStdout(fn: () => Promise<void>): Promise<string> {
179
+ const chunks: string[] = [];
180
+ const originalWrite = process.stdout.write;
181
+ process.stdout.write = ((chunk: string) => {
182
+ chunks.push(chunk);
183
+ return true;
184
+ }) as typeof process.stdout.write;
185
+ try {
186
+ await fn();
187
+ } finally {
188
+ process.stdout.write = originalWrite;
189
+ }
190
+ return chunks.join("");
191
+ }
192
+
193
+ /** Build default GatewayDeps with fake tmux and sleep.
194
+ * Always injects fakes for tmux to prevent real tmux calls in tests. */
195
+ function makeDeps(sessionAliveMap: Record<string, boolean> = {}): {
196
+ deps: GatewayDeps;
197
+ calls: TmuxCallTracker;
198
+ } {
199
+ const { tmux, calls } = makeFakeTmux(sessionAliveMap);
200
+
201
+ const deps: GatewayDeps = {
202
+ _tmux: tmux,
203
+ _sleep: () => Promise.resolve(),
204
+ };
205
+
206
+ return { deps, calls };
207
+ }
208
+
209
+ // --- Tests ---
210
+
211
+ describe("gatewayCommand help", () => {
212
+ test("--help outputs help text", async () => {
213
+ const output = await captureStdout(() => gatewayCommand(["--help"]));
214
+ expect(output).toContain("legio gateway");
215
+ expect(output).toContain("start");
216
+ expect(output).toContain("stop");
217
+ expect(output).toContain("status");
218
+ });
219
+
220
+ test("--help includes --attach and --no-attach flags", async () => {
221
+ const output = await captureStdout(() => gatewayCommand(["--help"]));
222
+ expect(output).toContain("--attach");
223
+ expect(output).toContain("--no-attach");
224
+ });
225
+
226
+ test("-h outputs help text", async () => {
227
+ const output = await captureStdout(() => gatewayCommand(["-h"]));
228
+ expect(output).toContain("legio gateway");
229
+ });
230
+
231
+ test("empty args outputs help text", async () => {
232
+ const output = await captureStdout(() => gatewayCommand([]));
233
+ expect(output).toContain("legio gateway");
234
+ expect(output).toContain("Subcommands:");
235
+ });
236
+ });
237
+
238
+ describe("gatewayCommand unknown subcommand", () => {
239
+ test("throws ValidationError for unknown subcommand", async () => {
240
+ await expect(gatewayCommand(["frobnicate"])).rejects.toThrow(ValidationError);
241
+ });
242
+
243
+ test("error message includes the bad subcommand name", async () => {
244
+ try {
245
+ await gatewayCommand(["frobnicate"]);
246
+ expect.unreachable("should have thrown");
247
+ } catch (err) {
248
+ expect(err).toBeInstanceOf(ValidationError);
249
+ const ve = err as ValidationError;
250
+ expect(ve.message).toContain("frobnicate");
251
+ expect(ve.field).toBe("subcommand");
252
+ expect(ve.value).toBe("frobnicate");
253
+ }
254
+ });
255
+ });
256
+
257
+ describe("startGateway", () => {
258
+ test("writes session to sessions.db with correct fields", async () => {
259
+ const { deps, calls } = makeDeps();
260
+
261
+ await captureStdout(() => gatewayCommand(["start"], deps));
262
+
263
+ // Verify sessions.db was written
264
+ const sessions = loadSessionsFromDb();
265
+ expect(sessions).toHaveLength(1);
266
+
267
+ const session = sessions[0];
268
+ expect(session).toBeDefined();
269
+ expect(session?.agentName).toBe("gateway");
270
+ expect(session?.capability).toBe("gateway");
271
+ expect(session?.tmuxSession).toBe("legio-test-project-gateway");
272
+ expect(session?.state).toBe("booting");
273
+ expect(session?.pid).toBe(99999);
274
+ expect(session?.parentAgent).toBeNull();
275
+ expect(session?.depth).toBe(0);
276
+ expect(session?.beadId).toBe("");
277
+ expect(session?.branchName).toBe("main");
278
+ expect(session?.worktreePath).toBe(tempDir);
279
+ expect(session?.id).toMatch(/^session-\d+-gateway$/);
280
+
281
+ // Verify tmux createSession was called
282
+ expect(calls.createSession).toHaveLength(1);
283
+ expect(calls.createSession[0]?.name).toBe("legio-test-project-gateway");
284
+ expect(calls.createSession[0]?.cwd).toBe(tempDir);
285
+
286
+ // Verify sendKeys was called (beacon + follow-up Enter)
287
+ expect(calls.sendKeys.length).toBeGreaterThanOrEqual(1);
288
+ });
289
+
290
+ test("deploys hooks to project root .claude/settings.local.json", async () => {
291
+ const { deps } = makeDeps();
292
+
293
+ await captureStdout(() => gatewayCommand(["start", "--no-attach"], deps));
294
+
295
+ // Verify .claude/settings.local.json was created at the project root
296
+ const settingsPath = join(tempDir, ".claude", "settings.local.json");
297
+ let settingsExists = false;
298
+ try {
299
+ await access(settingsPath);
300
+ settingsExists = true;
301
+ } catch {
302
+ // not found
303
+ }
304
+ expect(settingsExists).toBe(true);
305
+
306
+ const content = await readFile(settingsPath, "utf-8");
307
+ const config = JSON.parse(content) as {
308
+ hooks: Record<string, unknown[]>;
309
+ };
310
+
311
+ // Verify hook categories exist
312
+ expect(config.hooks).toBeDefined();
313
+ expect(config.hooks.SessionStart).toBeDefined();
314
+ expect(config.hooks.UserPromptSubmit).toBeDefined();
315
+ expect(config.hooks.PreToolUse).toBeDefined();
316
+ expect(config.hooks.PostToolUse).toBeDefined();
317
+ expect(config.hooks.Stop).toBeDefined();
318
+ });
319
+
320
+ test("hooks use gateway agent name for event logging", async () => {
321
+ const { deps } = makeDeps();
322
+
323
+ await captureStdout(() => gatewayCommand(["start", "--no-attach"], deps));
324
+
325
+ const settingsPath = join(tempDir, ".claude", "settings.local.json");
326
+ const content = await readFile(settingsPath, "utf-8");
327
+
328
+ // The hooks should reference the gateway agent name
329
+ expect(content).toContain("--agent gateway");
330
+ });
331
+
332
+ test("hooks include ENV_GUARD to avoid affecting user's Claude Code session", async () => {
333
+ const { deps } = makeDeps();
334
+
335
+ await captureStdout(() => gatewayCommand(["start", "--no-attach"], deps));
336
+
337
+ const settingsPath = join(tempDir, ".claude", "settings.local.json");
338
+ const content = await readFile(settingsPath, "utf-8");
339
+
340
+ // PreToolUse guards should include the ENV_GUARD prefix
341
+ expect(content).toContain("LEGIO_AGENT_NAME");
342
+ });
343
+
344
+ test("injects agent definition via --settings file when agent-defs/gateway.md exists", async () => {
345
+ // Deploy a gateway agent definition
346
+ const agentDefsDir = join(legioDir, "agent-defs");
347
+ await mkdir(agentDefsDir, { recursive: true });
348
+ await writeFile(join(agentDefsDir, "gateway.md"), "# Gateway Agent\n\nYou are the gateway.\n");
349
+
350
+ const { deps, calls } = makeDeps();
351
+
352
+ await captureStdout(() => gatewayCommand(["start", "--no-attach", "--json"], deps));
353
+
354
+ expect(calls.createSession).toHaveLength(1);
355
+ const cmd = calls.createSession[0]?.command ?? "";
356
+ // Agent def is written to a settings JSON file and passed via --settings
357
+ expect(cmd).toContain("--settings");
358
+ expect(cmd).toContain("settings-gateway.json");
359
+
360
+ // Verify the settings file was written with the agent def
361
+ const { readFileSync } = await import("node:fs");
362
+ const settingsPath = join(legioDir, "settings-gateway.json");
363
+ const settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as Record<string, unknown>;
364
+ expect(settings.appendSystemPrompt).toContain("# Gateway Agent");
365
+ expect(settings.skipDangerousModePermissionPrompt).toBe(true);
366
+ });
367
+
368
+ test("reads model from manifest instead of hardcoding", async () => {
369
+ // Override the manifest to use opus instead of default sonnet
370
+ const manifest = {
371
+ version: "1.0",
372
+ agents: {
373
+ gateway: {
374
+ file: "gateway.md",
375
+ model: "opus",
376
+ tools: ["Read", "Bash"],
377
+ capabilities: ["plan"],
378
+ canSpawn: false,
379
+ constraints: [],
380
+ },
381
+ },
382
+ capabilityIndex: { plan: ["gateway"] },
383
+ };
384
+ await writeFile(
385
+ join(legioDir, "agent-manifest.json"),
386
+ `${JSON.stringify(manifest, null, "\t")}\n`,
387
+ );
388
+
389
+ const { deps, calls } = makeDeps();
390
+
391
+ await captureStdout(() => gatewayCommand(["start", "--no-attach", "--json"], deps));
392
+
393
+ expect(calls.createSession).toHaveLength(1);
394
+ const cmd = calls.createSession[0]?.command ?? "";
395
+ expect(cmd).toContain("--model opus");
396
+ expect(cmd).not.toContain("--model sonnet");
397
+ });
398
+
399
+ test("defaults to opus model when not in manifest", async () => {
400
+ // Write manifest without gateway entry so resolveModel falls back to default
401
+ const manifest = {
402
+ version: "1.0",
403
+ agents: {},
404
+ capabilityIndex: {},
405
+ };
406
+ await writeFile(
407
+ join(legioDir, "agent-manifest.json"),
408
+ `${JSON.stringify(manifest, null, "\t")}\n`,
409
+ );
410
+
411
+ const { deps, calls } = makeDeps();
412
+
413
+ await captureStdout(() => gatewayCommand(["start", "--no-attach", "--json"], deps));
414
+
415
+ expect(calls.createSession).toHaveLength(1);
416
+ const cmd = calls.createSession[0]?.command ?? "";
417
+ expect(cmd).toContain("--model opus");
418
+ });
419
+
420
+ test("--json outputs JSON with expected fields", async () => {
421
+ const { deps } = makeDeps();
422
+
423
+ const output = await captureStdout(() => gatewayCommand(["start", "--json"], deps));
424
+
425
+ const parsed = JSON.parse(output) as Record<string, unknown>;
426
+ expect(parsed.agentName).toBe("gateway");
427
+ expect(parsed.capability).toBe("gateway");
428
+ expect(parsed.tmuxSession).toBe("legio-test-project-gateway");
429
+ expect(parsed.pid).toBe(99999);
430
+ expect(parsed.projectRoot).toBe(tempDir);
431
+ });
432
+
433
+ test("rejects duplicate when gateway is already running", async () => {
434
+ // Write an existing active gateway session
435
+ const existing = makeGatewaySession({ state: "working" });
436
+ saveSessionsToDb([existing]);
437
+
438
+ // Mock tmux as alive for the existing session
439
+ const { deps } = makeDeps({ "legio-test-project-gateway": true });
440
+
441
+ await expect(gatewayCommand(["start"], deps)).rejects.toThrow(AgentError);
442
+
443
+ try {
444
+ await gatewayCommand(["start"], deps);
445
+ } catch (err) {
446
+ expect(err).toBeInstanceOf(AgentError);
447
+ const ae = err as AgentError;
448
+ expect(ae.message).toContain("already running");
449
+ }
450
+ });
451
+
452
+ test("cleans up dead session and starts new one", async () => {
453
+ // Write an existing session that claims to be working
454
+ const deadSession = makeGatewaySession({
455
+ id: "session-dead-gateway",
456
+ state: "working",
457
+ });
458
+ saveSessionsToDb([deadSession]);
459
+
460
+ // Mock tmux as NOT alive for the existing session
461
+ const { deps } = makeDeps({ "legio-test-project-gateway": false });
462
+
463
+ await captureStdout(() => gatewayCommand(["start"], deps));
464
+
465
+ // SessionStore uses UNIQUE(agent_name), so the new session replaces the old one.
466
+ const sessions = loadSessionsFromDb();
467
+ expect(sessions).toHaveLength(1);
468
+
469
+ const newSession = sessions[0];
470
+ expect(newSession).toBeDefined();
471
+ expect(newSession?.state).toBe("booting");
472
+ expect(newSession?.agentName).toBe("gateway");
473
+ // The new session should have a different ID than the dead one
474
+ expect(newSession?.id).not.toBe("session-dead-gateway");
475
+ });
476
+ });
477
+
478
+ describe("stopGateway", () => {
479
+ test("marks session as completed after stopping", async () => {
480
+ const session = makeGatewaySession({ state: "working" });
481
+ saveSessionsToDb([session]);
482
+
483
+ // Tmux is alive so killSession will be called
484
+ const { deps, calls } = makeDeps({ "legio-test-project-gateway": true });
485
+
486
+ await captureStdout(() => gatewayCommand(["stop"], deps));
487
+
488
+ // Verify session is now completed
489
+ const sessions = loadSessionsFromDb();
490
+ expect(sessions).toHaveLength(1);
491
+ expect(sessions[0]?.state).toBe("completed");
492
+
493
+ // Verify killSession was called
494
+ expect(calls.killSession).toHaveLength(1);
495
+ expect(calls.killSession[0]?.name).toBe("legio-test-project-gateway");
496
+ });
497
+
498
+ test("--json outputs JSON with stopped flag", async () => {
499
+ const session = makeGatewaySession({ state: "working" });
500
+ saveSessionsToDb([session]);
501
+ const { deps } = makeDeps({ "legio-test-project-gateway": true });
502
+
503
+ const output = await captureStdout(() => gatewayCommand(["stop", "--json"], deps));
504
+ const parsed = JSON.parse(output) as Record<string, unknown>;
505
+ expect(parsed.stopped).toBe(true);
506
+ expect(parsed.sessionId).toBe(session.id);
507
+ });
508
+
509
+ test("handles already-dead tmux session gracefully", async () => {
510
+ const session = makeGatewaySession({ state: "working" });
511
+ saveSessionsToDb([session]);
512
+
513
+ // Tmux is NOT alive — should skip killSession
514
+ const { deps, calls } = makeDeps({ "legio-test-project-gateway": false });
515
+
516
+ await captureStdout(() => gatewayCommand(["stop"], deps));
517
+
518
+ // Verify session is completed
519
+ const sessions = loadSessionsFromDb();
520
+ expect(sessions[0]?.state).toBe("completed");
521
+
522
+ // killSession should NOT have been called since session was already dead
523
+ expect(calls.killSession).toHaveLength(0);
524
+ });
525
+
526
+ test("throws AgentError when no gateway session exists", async () => {
527
+ const { deps } = makeDeps();
528
+
529
+ await expect(gatewayCommand(["stop"], deps)).rejects.toThrow(AgentError);
530
+
531
+ try {
532
+ await gatewayCommand(["stop"], deps);
533
+ } catch (err) {
534
+ expect(err).toBeInstanceOf(AgentError);
535
+ const ae = err as AgentError;
536
+ expect(ae.message).toContain("No active gateway session");
537
+ }
538
+ });
539
+
540
+ test("throws AgentError when only completed sessions exist", async () => {
541
+ const completed = makeGatewaySession({ state: "completed" });
542
+ saveSessionsToDb([completed]);
543
+ const { deps } = makeDeps();
544
+
545
+ await expect(gatewayCommand(["stop"], deps)).rejects.toThrow(AgentError);
546
+ });
547
+ });
548
+
549
+ describe("statusGateway", () => {
550
+ test("shows 'not running' when no session exists", async () => {
551
+ const { deps } = makeDeps();
552
+ const output = await captureStdout(() => gatewayCommand(["status"], deps));
553
+ expect(output).toContain("not running");
554
+ });
555
+
556
+ test("--json shows running:false when no session exists", async () => {
557
+ const { deps } = makeDeps();
558
+ const output = await captureStdout(() => gatewayCommand(["status", "--json"], deps));
559
+ const parsed = JSON.parse(output) as Record<string, unknown>;
560
+ expect(parsed.running).toBe(false);
561
+ });
562
+
563
+ test("shows running state when gateway is alive", async () => {
564
+ const session = makeGatewaySession({ state: "working" });
565
+ saveSessionsToDb([session]);
566
+ const { deps } = makeDeps({ "legio-test-project-gateway": true });
567
+
568
+ const output = await captureStdout(() => gatewayCommand(["status"], deps));
569
+ expect(output).toContain("running");
570
+ expect(output).toContain(session.id);
571
+ expect(output).toContain("legio-test-project-gateway");
572
+ });
573
+
574
+ test("--json shows correct fields when running", async () => {
575
+ const session = makeGatewaySession({ state: "working", pid: 99999 });
576
+ saveSessionsToDb([session]);
577
+ const { deps } = makeDeps({ "legio-test-project-gateway": true });
578
+
579
+ const output = await captureStdout(() => gatewayCommand(["status", "--json"], deps));
580
+ const parsed = JSON.parse(output) as Record<string, unknown>;
581
+ expect(parsed.running).toBe(true);
582
+ expect(parsed.sessionId).toBe(session.id);
583
+ expect(parsed.state).toBe("working");
584
+ expect(parsed.tmuxSession).toBe("legio-test-project-gateway");
585
+ expect(parsed.pid).toBe(99999);
586
+ });
587
+
588
+ test("reconciles zombie: updates state when tmux is dead but session says working", async () => {
589
+ const session = makeGatewaySession({ state: "working" });
590
+ saveSessionsToDb([session]);
591
+
592
+ // Tmux is NOT alive — triggers zombie reconciliation
593
+ const { deps } = makeDeps({ "legio-test-project-gateway": false });
594
+
595
+ const output = await captureStdout(() => gatewayCommand(["status", "--json"], deps));
596
+ const parsed = JSON.parse(output) as Record<string, unknown>;
597
+ expect(parsed.running).toBe(false);
598
+ expect(parsed.state).toBe("zombie");
599
+
600
+ // Verify sessions.db was updated
601
+ const sessions = loadSessionsFromDb();
602
+ expect(sessions[0]?.state).toBe("zombie");
603
+ });
604
+
605
+ test("reconciles zombie for booting state too", async () => {
606
+ const session = makeGatewaySession({ state: "booting" });
607
+ saveSessionsToDb([session]);
608
+ const { deps } = makeDeps({ "legio-test-project-gateway": false });
609
+
610
+ const output = await captureStdout(() => gatewayCommand(["status", "--json"], deps));
611
+ const parsed = JSON.parse(output) as Record<string, unknown>;
612
+ expect(parsed.state).toBe("zombie");
613
+ });
614
+
615
+ test("does not show completed sessions as active", async () => {
616
+ const completed = makeGatewaySession({ state: "completed" });
617
+ saveSessionsToDb([completed]);
618
+ const { deps } = makeDeps();
619
+
620
+ const output = await captureStdout(() => gatewayCommand(["status", "--json"], deps));
621
+ const parsed = JSON.parse(output) as Record<string, unknown>;
622
+ expect(parsed.running).toBe(false);
623
+ });
624
+ });
625
+
626
+ describe("buildGatewayBeacon", () => {
627
+ test("is a single line (no newlines)", () => {
628
+ const beacon = buildGatewayBeacon();
629
+ expect(beacon).not.toContain("\n");
630
+ });
631
+
632
+ test("includes gateway identity in header", () => {
633
+ const beacon = buildGatewayBeacon();
634
+ expect(beacon).toContain("[LEGIO] gateway (gateway)");
635
+ });
636
+
637
+ test("includes ISO timestamp", () => {
638
+ const beacon = buildGatewayBeacon();
639
+ expect(beacon).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
640
+ });
641
+
642
+ test("includes depth and role info", () => {
643
+ const beacon = buildGatewayBeacon();
644
+ expect(beacon).toContain("Depth: 0 | Role: planning companion");
645
+ });
646
+
647
+ test("includes READONLY notice", () => {
648
+ const beacon = buildGatewayBeacon();
649
+ expect(beacon).toContain("READONLY: No Write/Edit");
650
+ });
651
+
652
+ test("includes ISSUES notice", () => {
653
+ const beacon = buildGatewayBeacon();
654
+ expect(beacon).toContain("ISSUES: Use bd create");
655
+ });
656
+
657
+ test("includes startup instructions", () => {
658
+ const beacon = buildGatewayBeacon();
659
+ expect(beacon).toContain("mulch prime");
660
+ expect(beacon).toContain("legio mail check --agent gateway");
661
+ expect(beacon).toContain("respond to user");
662
+ });
663
+
664
+ test("parts are joined with em-dash separator", () => {
665
+ const beacon = buildGatewayBeacon();
666
+ // Should have exactly 4 " — " separators (5 parts)
667
+ const dashes = beacon.split(" — ");
668
+ expect(dashes).toHaveLength(5);
669
+ });
670
+ });
671
+
672
+ describe("resolveAttach", () => {
673
+ test("--attach flag forces attach regardless of TTY", () => {
674
+ expect(resolveAttach(["--attach"], false)).toBe(true);
675
+ expect(resolveAttach(["--attach"], true)).toBe(true);
676
+ });
677
+
678
+ test("--no-attach flag forces no attach regardless of TTY", () => {
679
+ expect(resolveAttach(["--no-attach"], false)).toBe(false);
680
+ expect(resolveAttach(["--no-attach"], true)).toBe(false);
681
+ });
682
+
683
+ test("--attach takes precedence when both flags are present", () => {
684
+ expect(resolveAttach(["--attach", "--no-attach"], false)).toBe(true);
685
+ expect(resolveAttach(["--attach", "--no-attach"], true)).toBe(true);
686
+ });
687
+
688
+ test("defaults to TTY state when no flag is set", () => {
689
+ expect(resolveAttach([], true)).toBe(true);
690
+ expect(resolveAttach([], false)).toBe(false);
691
+ });
692
+
693
+ test("works with other flags present", () => {
694
+ expect(resolveAttach(["--json", "--attach"], false)).toBe(true);
695
+ expect(resolveAttach(["--json", "--no-attach"], true)).toBe(false);
696
+ expect(resolveAttach(["--json"], true)).toBe(true);
697
+ });
698
+ });