@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,288 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import type { WebSocket } from "ws";
6
+ import { createWebSocketManager, type WebSocketData } from "./websocket.ts";
7
+
8
+ let tempDir: string;
9
+ let legioDir: string;
10
+
11
+ beforeEach(async () => {
12
+ tempDir = await mkdtemp(join(tmpdir(), "ws-test-"));
13
+ legioDir = join(tempDir, ".legio");
14
+ await mkdir(legioDir, { recursive: true });
15
+ await writeFile(
16
+ join(legioDir, "config.yaml"),
17
+ "project:\n name: test\n canonicalBranch: main\nagents:\n maxDepth: 2\ncoordinator:\n model: sonnet\n",
18
+ );
19
+ });
20
+
21
+ afterEach(async () => {
22
+ await rm(tempDir, { recursive: true, force: true });
23
+ });
24
+
25
+ describe("createWebSocketManager", () => {
26
+ it("returns a manager with the expected interface", () => {
27
+ const manager = createWebSocketManager(legioDir);
28
+ expect(typeof manager.addClient).toBe("function");
29
+ expect(typeof manager.removeClient).toBe("function");
30
+ expect(typeof manager.handleMessage).toBe("function");
31
+ expect(typeof manager.startPolling).toBe("function");
32
+ expect(typeof manager.stopPolling).toBe("function");
33
+ });
34
+
35
+ it("startPolling and stopPolling do not throw", () => {
36
+ const manager = createWebSocketManager(legioDir);
37
+ expect(() => manager.startPolling()).not.toThrow();
38
+ expect(() => manager.stopPolling()).not.toThrow();
39
+ });
40
+
41
+ it("stopPolling is idempotent", () => {
42
+ const manager = createWebSocketManager(legioDir);
43
+ expect(() => {
44
+ manager.stopPolling();
45
+ manager.stopPolling();
46
+ }).not.toThrow();
47
+ });
48
+
49
+ it("gatherSnapshot returns valid shape with missing dbs", () => {
50
+ // Use a non-existent dir — all stores will fail to open and fall back to empty data
51
+ const manager = createWebSocketManager(join(tempDir, "no-such-dir"));
52
+ // We test gatherSnapshot indirectly via addClient's initial snapshot send
53
+ let received: string | null = null;
54
+ const fakeWs = {
55
+ send(msg: string) {
56
+ received = msg;
57
+ },
58
+ } as unknown as WebSocket;
59
+ // WebSocketData is unused at runtime but keeps test aligned with implementation
60
+ const _unused: WebSocketData = { connectedAt: "" };
61
+
62
+ manager.addClient(fakeWs);
63
+
64
+ expect(received).not.toBeNull();
65
+ if (received === null) throw new Error("expected a message");
66
+ const parsed = JSON.parse(received);
67
+ expect(parsed.type).toBe("snapshot");
68
+ expect(parsed.data).toBeDefined();
69
+ expect(Array.isArray(parsed.data.agents)).toBe(true);
70
+ expect(typeof parsed.data.mail.unreadCount).toBe("number");
71
+ expect(Array.isArray(parsed.data.mergeQueue)).toBe(true);
72
+ expect(typeof parsed.data.metricsSummary.totalSessions).toBe("number");
73
+ expect(parsed.timestamp).toBeDefined();
74
+ });
75
+
76
+ it("addClient sends initial snapshot immediately", () => {
77
+ const manager = createWebSocketManager(legioDir);
78
+ let received: string | null = null;
79
+
80
+ const fakeWs = {
81
+ send(msg: string) {
82
+ received = msg;
83
+ },
84
+ } as unknown as WebSocket;
85
+
86
+ manager.addClient(fakeWs);
87
+ expect(received).not.toBeNull();
88
+ if (received === null) throw new Error("expected a message");
89
+
90
+ const snapshot = JSON.parse(received);
91
+ expect(snapshot.type).toBe("snapshot");
92
+ });
93
+
94
+ it("removeClient stops further sends to that client", () => {
95
+ const manager = createWebSocketManager(legioDir);
96
+ let count = 0;
97
+
98
+ const fakeWs = {
99
+ send() {
100
+ count++;
101
+ },
102
+ } as unknown as WebSocket;
103
+
104
+ manager.addClient(fakeWs); // triggers 1 send (initial snapshot)
105
+ const countAfterAdd = count;
106
+ expect(countAfterAdd).toBe(1);
107
+
108
+ manager.removeClient(fakeWs);
109
+
110
+ // Trigger broadcast — should not reach removed client
111
+ const fakeWs2 = {
112
+ send() {
113
+ count++;
114
+ },
115
+ } as unknown as WebSocket;
116
+ manager.addClient(fakeWs2); // sends to ws2, not to ws1
117
+
118
+ // count should only increase by 1 (for fakeWs2 add), not for fakeWs
119
+ expect(count).toBe(2);
120
+ });
121
+
122
+ it("handleMessage with {type:'refresh'} sends a snapshot to that client", () => {
123
+ const manager = createWebSocketManager(legioDir);
124
+ const received: string[] = [];
125
+
126
+ const fakeWs = {
127
+ send(msg: string) {
128
+ received.push(msg);
129
+ },
130
+ } as unknown as WebSocket;
131
+
132
+ manager.addClient(fakeWs); // initial snapshot = received[0]
133
+ manager.handleMessage(fakeWs, Buffer.from(JSON.stringify({ type: "refresh" })));
134
+
135
+ expect(received.length).toBe(2);
136
+ const refreshed = JSON.parse(received[1] as string);
137
+ expect(refreshed.type).toBe("snapshot");
138
+ });
139
+
140
+ it("handleMessage with unknown type does not throw", () => {
141
+ const manager = createWebSocketManager(legioDir);
142
+ const fakeWs = {
143
+ send() {},
144
+ } as unknown as WebSocket;
145
+
146
+ manager.addClient(fakeWs);
147
+ expect(() =>
148
+ manager.handleMessage(fakeWs, Buffer.from(JSON.stringify({ type: "unknown" }))),
149
+ ).not.toThrow();
150
+ });
151
+
152
+ it("handleMessage with invalid JSON does not throw", () => {
153
+ const manager = createWebSocketManager(legioDir);
154
+ const fakeWs = {
155
+ send() {},
156
+ } as unknown as WebSocket;
157
+
158
+ manager.addClient(fakeWs);
159
+ expect(() => manager.handleMessage(fakeWs, Buffer.from("not-json{{{"))).not.toThrow();
160
+ });
161
+
162
+ it("broadcastEvent sends event to all connected clients", () => {
163
+ const manager = createWebSocketManager(legioDir);
164
+ const messagesA: string[] = [];
165
+ const messagesB: string[] = [];
166
+
167
+ const ws1 = {
168
+ send(msg: string) {
169
+ messagesA.push(msg);
170
+ },
171
+ } as unknown as WebSocket;
172
+
173
+ const ws2 = {
174
+ send(msg: string) {
175
+ messagesB.push(msg);
176
+ },
177
+ } as unknown as WebSocket;
178
+
179
+ manager.addClient(ws1); // initial snapshot
180
+ manager.addClient(ws2); // initial snapshot
181
+
182
+ manager.broadcastEvent({ type: "mail_new", data: { id: "msg-test-001" } });
183
+
184
+ // Each client received: 1 initial snapshot + 1 broadcastEvent
185
+ expect(messagesA.length).toBe(2);
186
+ expect(messagesB.length).toBe(2);
187
+
188
+ const evtA = JSON.parse(messagesA[1] as string);
189
+ expect(evtA.type).toBe("mail_new");
190
+ expect(evtA.timestamp).toBeDefined();
191
+ expect((evtA.data as { id: string }).id).toBe("msg-test-001");
192
+ });
193
+
194
+ it("broadcastEvent with no clients does not throw", () => {
195
+ const manager = createWebSocketManager(legioDir);
196
+ expect(() => manager.broadcastEvent({ type: "mail_new" })).not.toThrow();
197
+ });
198
+
199
+ it("broadcastEvent event format includes timestamp", () => {
200
+ const manager = createWebSocketManager(legioDir);
201
+ const received: string[] = [];
202
+
203
+ const fakeWs = {
204
+ send(msg: string) {
205
+ received.push(msg);
206
+ },
207
+ } as unknown as WebSocket;
208
+
209
+ manager.addClient(fakeWs); // initial snapshot (received[0])
210
+ manager.broadcastEvent({ type: "test_event", data: { foo: "bar" } });
211
+
212
+ expect(received.length).toBe(2);
213
+ const evt = JSON.parse(received[1] as string);
214
+ expect(evt.type).toBe("test_event");
215
+ expect(typeof evt.timestamp).toBe("string");
216
+ expect(() => new Date(evt.timestamp)).not.toThrow();
217
+ expect((evt.data as { foo: string }).foo).toBe("bar");
218
+ });
219
+
220
+ it("multiple clients receive snapshot when added", () => {
221
+ const manager = createWebSocketManager(legioDir);
222
+ const messages: Record<string, string[]> = { a: [], b: [] };
223
+
224
+ const ws1 = {
225
+ send(msg: string) {
226
+ messages.a?.push(msg);
227
+ },
228
+ } as unknown as WebSocket;
229
+
230
+ const ws2 = {
231
+ send(msg: string) {
232
+ messages.b?.push(msg);
233
+ },
234
+ } as unknown as WebSocket;
235
+
236
+ manager.addClient(ws1);
237
+ manager.addClient(ws2);
238
+
239
+ expect(messages.a?.length).toBe(1);
240
+ expect(messages.b?.length).toBe(1);
241
+
242
+ const snap1 = JSON.parse(messages.a?.[0] ?? "{}");
243
+ const snap2 = JSON.parse(messages.b?.[0] ?? "{}");
244
+ expect(snap1.type).toBe("snapshot");
245
+ expect(snap2.type).toBe("snapshot");
246
+ });
247
+ });
248
+
249
+ describe("WebSocket integration (real server)", () => {
250
+ it("client receives initial snapshot on connect and can request refresh", async () => {
251
+ // Dynamic import to avoid top-level routes.ts dependency issues
252
+ const { createServer } = await import("./index.ts");
253
+ const { WebSocket: WsClient } = await import("ws");
254
+
255
+ const server = await createServer({ port: 0, host: "localhost", root: tempDir });
256
+ const port = server.port;
257
+
258
+ try {
259
+ const ws = new WsClient(`ws://localhost:${port}/ws`);
260
+
261
+ const messages: string[] = [];
262
+ await new Promise<void>((resolve, reject) => {
263
+ const timeout = setTimeout(() => reject(new Error("timeout")), 5000);
264
+ ws.onmessage = (e) => {
265
+ messages.push(e.data as string);
266
+ if (messages.length === 1) {
267
+ // Got initial snapshot, send refresh
268
+ ws.send(JSON.stringify({ type: "refresh" }));
269
+ } else {
270
+ clearTimeout(timeout);
271
+ resolve();
272
+ }
273
+ };
274
+ ws.onerror = () => reject(new Error("ws error"));
275
+ });
276
+
277
+ expect(messages.length).toBeGreaterThanOrEqual(2);
278
+ const initial = JSON.parse(messages[0] as string);
279
+ expect(initial.type).toBe("snapshot");
280
+ const refreshed = JSON.parse(messages[1] as string);
281
+ expect(refreshed.type).toBe("snapshot");
282
+
283
+ ws.close();
284
+ } finally {
285
+ server.stop(true);
286
+ }
287
+ });
288
+ });
@@ -0,0 +1,196 @@
1
+ import { join } from "node:path";
2
+ import type { RawData, WebSocket } from "ws";
3
+ import { createMailStore } from "../mail/store.ts";
4
+ import { createMergeQueue } from "../merge/queue.ts";
5
+ import { createMetricsStore } from "../metrics/store.ts";
6
+ import { openSessionStore } from "../sessions/compat.ts";
7
+ import { createRunStore } from "../sessions/store.ts";
8
+ export interface WebSocketData {
9
+ connectedAt: string;
10
+ }
11
+
12
+ interface Snapshot {
13
+ type: "snapshot";
14
+ data: {
15
+ agents: unknown[];
16
+ mail: { unreadCount: number; recent: unknown[] };
17
+ mergeQueue: unknown[];
18
+ metricsSummary: { totalSessions: number; avgDuration: number };
19
+ runs: { active: unknown | null };
20
+ };
21
+ timestamp: string;
22
+ }
23
+
24
+ export interface WebSocketManager {
25
+ addClient(ws: WebSocket): void;
26
+ removeClient(ws: WebSocket): void;
27
+ handleMessage(ws: WebSocket, message: RawData): void;
28
+ startPolling(): void;
29
+ stopPolling(): void;
30
+ broadcastEvent(event: { type: string; data?: unknown }): void;
31
+ }
32
+
33
+ export function createWebSocketManager(legioDir: string): WebSocketManager {
34
+ const clients = new Set<WebSocket>();
35
+ let pollInterval: ReturnType<typeof setInterval> | null = null;
36
+ let lastSnapshot = "";
37
+
38
+ function gatherSnapshot(): Snapshot {
39
+ const data: Snapshot["data"] = {
40
+ agents: [],
41
+ mail: { unreadCount: 0, recent: [] },
42
+ mergeQueue: [],
43
+ metricsSummary: { totalSessions: 0, avgDuration: 0 },
44
+ runs: { active: null },
45
+ };
46
+
47
+ // Session store
48
+ try {
49
+ const { store } = openSessionStore(legioDir);
50
+ try {
51
+ data.agents = store.getActive();
52
+ } finally {
53
+ store.close();
54
+ }
55
+ } catch {
56
+ /* db may not exist */
57
+ }
58
+
59
+ // Mail store
60
+ try {
61
+ const mailPath = join(legioDir, "mail.db");
62
+ const store = createMailStore(mailPath);
63
+ try {
64
+ const all = store.getAll();
65
+ data.mail.unreadCount = all.filter((m: { read: boolean }) => !m.read).length;
66
+ data.mail.recent = all.slice(0, 20);
67
+ } finally {
68
+ store.close();
69
+ }
70
+ } catch {
71
+ /* db may not exist */
72
+ }
73
+
74
+ // Merge queue
75
+ try {
76
+ const queue = createMergeQueue(join(legioDir, "merge-queue.db"));
77
+ try {
78
+ data.mergeQueue = queue.list();
79
+ } finally {
80
+ queue.close();
81
+ }
82
+ } catch {
83
+ /* db may not exist */
84
+ }
85
+
86
+ // Metrics
87
+ try {
88
+ const store = createMetricsStore(join(legioDir, "metrics.db"));
89
+ try {
90
+ const sessions = store.getRecentSessions(100);
91
+ data.metricsSummary.totalSessions = sessions.length;
92
+ data.metricsSummary.avgDuration = store.getAverageDuration();
93
+ } finally {
94
+ store.close();
95
+ }
96
+ } catch {
97
+ /* db may not exist */
98
+ }
99
+
100
+ // Runs
101
+ try {
102
+ const store = createRunStore(join(legioDir, "sessions.db"));
103
+ try {
104
+ data.runs.active = store.getActiveRun();
105
+ } finally {
106
+ store.close();
107
+ }
108
+ } catch {
109
+ /* db may not exist */
110
+ }
111
+
112
+ return {
113
+ type: "snapshot",
114
+ data,
115
+ timestamp: new Date().toISOString(),
116
+ };
117
+ }
118
+
119
+ function broadcast(snapshot: Snapshot): void {
120
+ const msg = JSON.stringify(snapshot);
121
+ for (const client of clients) {
122
+ try {
123
+ client.send(msg);
124
+ } catch {
125
+ clients.delete(client);
126
+ }
127
+ }
128
+ }
129
+
130
+ return {
131
+ addClient(ws) {
132
+ clients.add(ws);
133
+ // Send initial snapshot immediately
134
+ const snapshot = gatherSnapshot();
135
+ try {
136
+ ws.send(JSON.stringify(snapshot));
137
+ } catch {
138
+ clients.delete(ws);
139
+ }
140
+ },
141
+
142
+ removeClient(ws) {
143
+ clients.delete(ws);
144
+ },
145
+
146
+ handleMessage(ws, message) {
147
+ try {
148
+ const msgStr =
149
+ typeof message === "string"
150
+ ? message
151
+ : Buffer.isBuffer(message)
152
+ ? message.toString("utf8")
153
+ : Array.isArray(message)
154
+ ? Buffer.concat(message as Buffer[]).toString("utf8")
155
+ : Buffer.from(message as ArrayBuffer).toString("utf8");
156
+ const parsed = JSON.parse(msgStr);
157
+ if (parsed && typeof parsed === "object" && "type" in parsed && parsed.type === "refresh") {
158
+ const snapshot = gatherSnapshot();
159
+ ws.send(JSON.stringify(snapshot));
160
+ }
161
+ } catch {
162
+ // Ignore invalid messages
163
+ }
164
+ },
165
+
166
+ startPolling() {
167
+ pollInterval = setInterval(() => {
168
+ if (clients.size === 0) return; // Skip if no clients
169
+ const snapshot = gatherSnapshot();
170
+ const snapshotStr = JSON.stringify(snapshot.data);
171
+ if (snapshotStr !== lastSnapshot) {
172
+ lastSnapshot = snapshotStr;
173
+ broadcast(snapshot);
174
+ }
175
+ }, 2000);
176
+ },
177
+
178
+ stopPolling() {
179
+ if (pollInterval) {
180
+ clearInterval(pollInterval);
181
+ pollInterval = null;
182
+ }
183
+ },
184
+
185
+ broadcastEvent(event) {
186
+ const msg = JSON.stringify({ ...event, timestamp: new Date().toISOString() });
187
+ for (const client of clients) {
188
+ try {
189
+ client.send(msg);
190
+ } catch {
191
+ clients.delete(client);
192
+ }
193
+ }
194
+ },
195
+ };
196
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Tests for the session compat shim.
3
+ *
4
+ * Uses real filesystem and better-sqlite3. No mocks.
5
+ */
6
+
7
+ import { mkdtemp, rm } from "node:fs/promises";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
11
+ import { openSessionStore } from "./compat.ts";
12
+
13
+ let tempDir: string;
14
+ let legioDir: string;
15
+
16
+ beforeEach(async () => {
17
+ tempDir = await mkdtemp(join(tmpdir(), "legio-compat-test-"));
18
+ legioDir = join(tempDir, ".legio");
19
+ const { mkdir } = await import("node:fs/promises");
20
+ await mkdir(legioDir, { recursive: true });
21
+ });
22
+
23
+ afterEach(async () => {
24
+ await rm(tempDir, { recursive: true, force: true });
25
+ });
26
+
27
+ describe("openSessionStore", () => {
28
+ test("creates empty DB when sessions.db does not exist", () => {
29
+ const { store, migrated } = openSessionStore(legioDir);
30
+
31
+ expect(migrated).toBe(false);
32
+ expect(store.getAll()).toEqual([]);
33
+ store.close();
34
+ });
35
+
36
+ test("returns migrated:false always", () => {
37
+ const { migrated } = openSessionStore(legioDir);
38
+ expect(migrated).toBe(false);
39
+ });
40
+
41
+ test("returned store supports all SessionStore operations", () => {
42
+ const { store } = openSessionStore(legioDir);
43
+
44
+ const now = new Date().toISOString();
45
+ store.upsert({
46
+ id: "s-001",
47
+ agentName: "test-agent",
48
+ capability: "builder",
49
+ worktreePath: "/tmp/worktrees/test-agent",
50
+ branchName: "legio/test-agent/task-1",
51
+ beadId: "task-1",
52
+ tmuxSession: "legio-test-agent",
53
+ state: "working",
54
+ pid: 12345,
55
+ parentAgent: null,
56
+ depth: 0,
57
+ runId: null,
58
+ startedAt: now,
59
+ lastActivity: now,
60
+ escalationLevel: 0,
61
+ stalledSince: null,
62
+ });
63
+
64
+ const all = store.getAll();
65
+ expect(all).toHaveLength(1);
66
+ expect(all[0]?.agentName).toBe("test-agent");
67
+
68
+ const active = store.getActive();
69
+ expect(active).toHaveLength(1);
70
+
71
+ store.updateState("test-agent", "completed");
72
+ expect(store.getByName("test-agent")?.state).toBe("completed");
73
+
74
+ store.remove("test-agent");
75
+ expect(store.getByName("test-agent")).toBeNull();
76
+
77
+ store.close();
78
+ });
79
+
80
+ test("second call opens existing DB", () => {
81
+ const { store: store1 } = openSessionStore(legioDir);
82
+ const now = new Date().toISOString();
83
+ store1.upsert({
84
+ id: "s-persist",
85
+ agentName: "persistent-agent",
86
+ capability: "scout",
87
+ worktreePath: "/tmp/worktrees/persistent-agent",
88
+ branchName: "legio/persistent-agent/task-2",
89
+ beadId: "task-2",
90
+ tmuxSession: "legio-persistent-agent",
91
+ state: "working",
92
+ pid: null,
93
+ parentAgent: null,
94
+ depth: 0,
95
+ runId: null,
96
+ startedAt: now,
97
+ lastActivity: now,
98
+ escalationLevel: 0,
99
+ stalledSince: null,
100
+ });
101
+ store1.close();
102
+
103
+ const { store: store2, migrated } = openSessionStore(legioDir);
104
+ expect(migrated).toBe(false);
105
+ expect(store2.getAll()).toHaveLength(1);
106
+ expect(store2.getByName("persistent-agent")?.id).toBe("s-persist");
107
+ store2.close();
108
+ });
109
+ });
@@ -0,0 +1,17 @@
1
+ import { join } from "node:path";
2
+ import { createSessionStore, type SessionStore } from "./store.ts";
3
+
4
+ /**
5
+ * Open or create a SessionStore at the given .legio directory root.
6
+ *
7
+ * @param legioDir - Path to the .legio directory (e.g., /project/.legio)
8
+ * @returns An object with the SessionStore and whether a migration occurred.
9
+ */
10
+ export function openSessionStore(legioDir: string): {
11
+ store: SessionStore;
12
+ migrated: boolean;
13
+ } {
14
+ const dbPath = join(legioDir, "sessions.db");
15
+ const store = createSessionStore(dbPath);
16
+ return { store, migrated: false };
17
+ }