@os-eco/overstory-cli 0.9.3 → 0.10.3

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 (116) hide show
  1. package/README.md +49 -18
  2. package/agents/builder.md +9 -8
  3. package/agents/coordinator.md +6 -6
  4. package/agents/lead.md +98 -82
  5. package/agents/merger.md +25 -14
  6. package/agents/reviewer.md +22 -16
  7. package/agents/scout.md +17 -12
  8. package/package.json +6 -3
  9. package/src/agents/capabilities.test.ts +85 -0
  10. package/src/agents/capabilities.ts +125 -0
  11. package/src/agents/headless-mail-injector.test.ts +448 -0
  12. package/src/agents/headless-mail-injector.ts +211 -0
  13. package/src/agents/headless-prompt.test.ts +102 -0
  14. package/src/agents/headless-prompt.ts +68 -0
  15. package/src/agents/hooks-deployer.test.ts +514 -14
  16. package/src/agents/hooks-deployer.ts +141 -0
  17. package/src/agents/overlay.test.ts +4 -4
  18. package/src/agents/overlay.ts +30 -8
  19. package/src/agents/turn-lock.test.ts +181 -0
  20. package/src/agents/turn-lock.ts +235 -0
  21. package/src/agents/turn-runner-dispatch.test.ts +182 -0
  22. package/src/agents/turn-runner-dispatch.ts +105 -0
  23. package/src/agents/turn-runner.test.ts +1450 -0
  24. package/src/agents/turn-runner.ts +1166 -0
  25. package/src/commands/clean.ts +56 -1
  26. package/src/commands/completions.test.ts +4 -1
  27. package/src/commands/coordinator.test.ts +127 -0
  28. package/src/commands/coordinator.ts +205 -6
  29. package/src/commands/dashboard.test.ts +188 -0
  30. package/src/commands/dashboard.ts +13 -3
  31. package/src/commands/doctor.ts +94 -77
  32. package/src/commands/group.test.ts +94 -0
  33. package/src/commands/group.ts +49 -20
  34. package/src/commands/init.test.ts +8 -0
  35. package/src/commands/init.ts +8 -1
  36. package/src/commands/log.test.ts +56 -11
  37. package/src/commands/log.ts +134 -69
  38. package/src/commands/mail.test.ts +162 -0
  39. package/src/commands/mail.ts +64 -9
  40. package/src/commands/merge.test.ts +112 -1
  41. package/src/commands/merge.ts +17 -4
  42. package/src/commands/monitor.ts +2 -1
  43. package/src/commands/nudge.test.ts +351 -4
  44. package/src/commands/nudge.ts +356 -34
  45. package/src/commands/run.test.ts +43 -7
  46. package/src/commands/serve/build.test.ts +202 -0
  47. package/src/commands/serve/build.ts +206 -0
  48. package/src/commands/serve/coordinator-actions.test.ts +339 -0
  49. package/src/commands/serve/coordinator-actions.ts +408 -0
  50. package/src/commands/serve/dev.test.ts +168 -0
  51. package/src/commands/serve/dev.ts +117 -0
  52. package/src/commands/serve/mail-actions.test.ts +312 -0
  53. package/src/commands/serve/mail-actions.ts +167 -0
  54. package/src/commands/serve/rest.test.ts +1323 -0
  55. package/src/commands/serve/rest.ts +708 -0
  56. package/src/commands/serve/static.ts +51 -0
  57. package/src/commands/serve/ws.test.ts +361 -0
  58. package/src/commands/serve/ws.ts +332 -0
  59. package/src/commands/serve.test.ts +459 -0
  60. package/src/commands/serve.ts +565 -0
  61. package/src/commands/sling.test.ts +85 -1
  62. package/src/commands/sling.ts +153 -64
  63. package/src/commands/status.test.ts +9 -0
  64. package/src/commands/status.ts +12 -4
  65. package/src/commands/stop.test.ts +174 -1
  66. package/src/commands/stop.ts +107 -8
  67. package/src/commands/supervisor.ts +2 -1
  68. package/src/commands/watch.test.ts +49 -4
  69. package/src/commands/watch.ts +153 -28
  70. package/src/commands/worktree.test.ts +319 -3
  71. package/src/commands/worktree.ts +86 -0
  72. package/src/config.test.ts +78 -0
  73. package/src/config.ts +43 -1
  74. package/src/doctor/consistency.test.ts +106 -0
  75. package/src/doctor/consistency.ts +50 -3
  76. package/src/doctor/serve.test.ts +95 -0
  77. package/src/doctor/serve.ts +86 -0
  78. package/src/doctor/types.ts +2 -1
  79. package/src/doctor/watchdog.ts +57 -1
  80. package/src/events/tailer.test.ts +234 -1
  81. package/src/events/tailer.ts +90 -0
  82. package/src/index.ts +53 -6
  83. package/src/json.ts +29 -0
  84. package/src/mail/client.ts +15 -2
  85. package/src/mail/store.test.ts +82 -0
  86. package/src/mail/store.ts +41 -4
  87. package/src/merge/lock.test.ts +149 -0
  88. package/src/merge/lock.ts +140 -0
  89. package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
  90. package/src/runtimes/claude.test.ts +791 -1
  91. package/src/runtimes/claude.ts +323 -1
  92. package/src/runtimes/connections.test.ts +141 -1
  93. package/src/runtimes/connections.ts +73 -4
  94. package/src/runtimes/headless-connection.test.ts +264 -0
  95. package/src/runtimes/headless-connection.ts +158 -0
  96. package/src/runtimes/types.ts +10 -0
  97. package/src/schema-consistency.test.ts +1 -0
  98. package/src/sessions/store.test.ts +390 -24
  99. package/src/sessions/store.ts +184 -19
  100. package/src/test-setup.test.ts +31 -0
  101. package/src/test-setup.ts +28 -0
  102. package/src/types.ts +56 -1
  103. package/src/utils/pid.test.ts +85 -1
  104. package/src/utils/pid.ts +86 -1
  105. package/src/utils/process-scan.test.ts +53 -0
  106. package/src/utils/process-scan.ts +76 -0
  107. package/src/watchdog/daemon.test.ts +1520 -411
  108. package/src/watchdog/daemon.ts +442 -83
  109. package/src/watchdog/health.test.ts +157 -0
  110. package/src/watchdog/health.ts +92 -25
  111. package/src/worktree/process.test.ts +71 -0
  112. package/src/worktree/process.ts +25 -5
  113. package/src/worktree/tmux.test.ts +39 -0
  114. package/src/worktree/tmux.ts +23 -3
  115. package/templates/CLAUDE.md.tmpl +19 -8
  116. package/templates/overlay.md.tmpl +3 -2
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Static-file serving with path-traversal guard for `ov serve`.
3
+ *
4
+ * Extracted from serve.ts so the path-traversal check can be tested in isolation.
5
+ */
6
+
7
+ import type { existsSync } from "node:fs";
8
+ import { resolve, sep } from "node:path";
9
+ import { apiError } from "../../json.ts";
10
+
11
+ /**
12
+ * Serve a static file from uiDistPath, falling back to index.html for SPA routes.
13
+ * Rejects requests that escape uiDistPath with a 403 JSON envelope.
14
+ * Returns a 503 JSON envelope when ui/dist is missing.
15
+ */
16
+ export async function serveStatic(
17
+ path: string,
18
+ uiDistPath: string,
19
+ _exists: typeof existsSync,
20
+ ): Promise<Response> {
21
+ if (!_exists(uiDistPath)) {
22
+ return apiError("UI not built — run the UI build first", 503);
23
+ }
24
+
25
+ // Normalise path: strip leading slash, default to index.html
26
+ const stripped = path.replace(/^\//, "") || "index.html";
27
+
28
+ const uiRoot = resolve(uiDistPath);
29
+ const filePath = resolve(uiRoot, stripped);
30
+
31
+ // Path-traversal guard: resolved path must stay inside uiRoot
32
+ if (filePath !== uiRoot && !filePath.startsWith(uiRoot + sep)) {
33
+ return apiError("Forbidden", 403);
34
+ }
35
+
36
+ const file = Bun.file(filePath);
37
+ if (await file.exists()) {
38
+ return new Response(file);
39
+ }
40
+
41
+ // SPA fallback: any unknown path → index.html
42
+ const indexPath = resolve(uiRoot, "index.html");
43
+ const indexFile = Bun.file(indexPath);
44
+ if (await indexFile.exists()) {
45
+ return new Response(indexFile, {
46
+ headers: { "Content-Type": "text/html; charset=utf-8" },
47
+ });
48
+ }
49
+
50
+ return new Response("Not found", { status: 404 });
51
+ }
@@ -0,0 +1,361 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { _resetHandlers, createServeServer } from "../serve.ts";
7
+ import { _getRoomCount, _resetRooms, installBroadcaster } from "./ws.ts";
8
+
9
+ /**
10
+ * Tests use createServeServer() directly (no process signals).
11
+ * Each test binds to port: 0 for conflict-free execution.
12
+ * installBroadcaster() is called explicitly in tests that need it.
13
+ */
14
+
15
+ const POLL_MS = 50; // fast polls for tests
16
+
17
+ describe("WebSocket broadcaster", () => {
18
+ let tempDir: string;
19
+ let eventsDbPath: string;
20
+ let mailDbPath: string;
21
+ let eventsDb: Database;
22
+ let mailDb: Database;
23
+ let servers: ReturnType<typeof Bun.serve>[] = [];
24
+ let stopBroadcasters: (() => void)[] = [];
25
+ let wsConnections: WebSocket[] = [];
26
+
27
+ beforeEach(() => {
28
+ tempDir = mkdtempSync(join(tmpdir(), "overstory-ws-test-"));
29
+ eventsDbPath = join(tempDir, "events.db");
30
+ mailDbPath = join(tempDir, "mail.db");
31
+
32
+ // Create .overstory/config.yaml so loadConfig resolves
33
+ mkdirSync(join(tempDir, ".overstory"), { recursive: true });
34
+ writeFileSync(
35
+ join(tempDir, ".overstory", "config.yaml"),
36
+ `project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
37
+ );
38
+
39
+ // Pre-create DBs with required schemas
40
+ eventsDb = new Database(eventsDbPath);
41
+ eventsDb.exec("PRAGMA journal_mode=WAL");
42
+ eventsDb.exec(`
43
+ CREATE TABLE IF NOT EXISTS events (
44
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
45
+ run_id TEXT,
46
+ agent_name TEXT NOT NULL,
47
+ session_id TEXT,
48
+ event_type TEXT NOT NULL,
49
+ tool_name TEXT,
50
+ tool_args TEXT,
51
+ tool_duration_ms INTEGER,
52
+ level TEXT NOT NULL DEFAULT 'info',
53
+ data TEXT,
54
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f','now'))
55
+ )
56
+ `);
57
+
58
+ mailDb = new Database(mailDbPath);
59
+ mailDb.exec("PRAGMA journal_mode=WAL");
60
+ mailDb.exec(`
61
+ CREATE TABLE IF NOT EXISTS messages (
62
+ id TEXT PRIMARY KEY,
63
+ from_agent TEXT NOT NULL,
64
+ to_agent TEXT NOT NULL,
65
+ subject TEXT NOT NULL,
66
+ body TEXT NOT NULL,
67
+ type TEXT NOT NULL DEFAULT 'status',
68
+ priority TEXT NOT NULL DEFAULT 'normal',
69
+ thread_id TEXT,
70
+ payload TEXT,
71
+ read INTEGER NOT NULL DEFAULT 0,
72
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f','now'))
73
+ )
74
+ `);
75
+ });
76
+
77
+ afterEach(async () => {
78
+ // Close WS connections
79
+ for (const ws of wsConnections) {
80
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
81
+ ws.close();
82
+ }
83
+ }
84
+ wsConnections = [];
85
+ // Stop broadcasters
86
+ for (const stop of stopBroadcasters) {
87
+ stop();
88
+ }
89
+ stopBroadcasters = [];
90
+ // Stop servers
91
+ for (const srv of servers) {
92
+ srv.stop(true);
93
+ }
94
+ servers = [];
95
+ // Reset handler and room state
96
+ _resetHandlers();
97
+ _resetRooms();
98
+ // Close test DBs
99
+ eventsDb.close();
100
+ mailDb.close();
101
+ // Remove temp dir
102
+ rmSync(tempDir, { recursive: true, force: true });
103
+ });
104
+
105
+ async function startWithBroadcaster(): Promise<ReturnType<typeof Bun.serve>> {
106
+ const stop = installBroadcaster({ eventsDbPath, mailDbPath, pollIntervalMs: POLL_MS });
107
+ stopBroadcasters.push(stop);
108
+ const origCwd = process.cwd;
109
+ process.cwd = () => tempDir;
110
+ const server = await createServeServer({ port: 0, host: "127.0.0.1" });
111
+ process.cwd = origCwd;
112
+ servers.push(server);
113
+ return server;
114
+ }
115
+
116
+ function waitForOpen(ws: WebSocket): Promise<void> {
117
+ return new Promise<void>((resolve, reject) => {
118
+ if (ws.readyState === WebSocket.OPEN) {
119
+ resolve();
120
+ return;
121
+ }
122
+ ws.addEventListener("open", () => resolve());
123
+ ws.addEventListener("error", () => reject(new Error("WebSocket error")));
124
+ });
125
+ }
126
+
127
+ function collectMessages(ws: WebSocket, count: number, timeoutMs = 2000): Promise<unknown[]> {
128
+ return new Promise<unknown[]>((resolve) => {
129
+ const messages: unknown[] = [];
130
+ const timer = setTimeout(() => resolve(messages), timeoutMs);
131
+ ws.addEventListener("message", (e: MessageEvent) => {
132
+ messages.push(JSON.parse(e.data as string));
133
+ if (messages.length >= count) {
134
+ clearTimeout(timer);
135
+ resolve(messages);
136
+ }
137
+ });
138
+ });
139
+ }
140
+
141
+ function insertEvent(
142
+ db: Database,
143
+ agentName: string,
144
+ runId: string | null,
145
+ eventType: string,
146
+ sessionId?: string | null,
147
+ data?: string | null,
148
+ ): void {
149
+ db.prepare(
150
+ "INSERT INTO events (agent_name, run_id, session_id, event_type, data, level) VALUES (?, ?, ?, ?, ?, 'info')",
151
+ ).run(agentName, runId, sessionId ?? null, eventType, data ?? null);
152
+ }
153
+
154
+ function insertMail(db: Database, id: string, fromAgent: string, toAgent: string): void {
155
+ const ts = new Date().toISOString();
156
+ db.prepare(
157
+ "INSERT INTO messages (id, from_agent, to_agent, subject, body, type, priority, read, created_at) VALUES (?, ?, ?, 'Subject', 'Body', 'status', 'normal', 0, ?)",
158
+ ).run(id, fromAgent, toAgent, ts);
159
+ }
160
+
161
+ // Test 1: ?run=<id> upgrades; event for matching run id arrives
162
+ test("?run=<id> upgrades and event for matching run arrives", async () => {
163
+ const server = await startWithBroadcaster();
164
+ const ws = new WebSocket(`ws://127.0.0.1:${server.port}/ws?run=run-123`);
165
+ wsConnections.push(ws);
166
+ await waitForOpen(ws);
167
+
168
+ const pending = collectMessages(ws, 1, 1000);
169
+ insertEvent(eventsDb, "agent-x", "run-123", "tool_start");
170
+
171
+ const msgs = await pending;
172
+ expect(msgs.length).toBe(1);
173
+ const frame = msgs[0] as Record<string, unknown>;
174
+ expect(frame.type).toBe("event");
175
+ const payload = frame.payload as Record<string, unknown>;
176
+ expect(payload.runId).toBe("run-123");
177
+ });
178
+
179
+ // Test 2: ?agent=<name> upgrades; event for that agent arrives
180
+ test("?agent=<name> upgrades and event for matching agent arrives", async () => {
181
+ const server = await startWithBroadcaster();
182
+ const ws = new WebSocket(`ws://127.0.0.1:${server.port}/ws?agent=agent-x`);
183
+ wsConnections.push(ws);
184
+ await waitForOpen(ws);
185
+
186
+ const pending = collectMessages(ws, 1, 1000);
187
+ insertEvent(eventsDb, "agent-x", null, "tool_start");
188
+
189
+ const msgs = await pending;
190
+ expect(msgs.length).toBe(1);
191
+ const frame = msgs[0] as Record<string, unknown>;
192
+ expect(frame.type).toBe("event");
193
+ const payload = frame.payload as Record<string, unknown>;
194
+ expect(payload.agentName).toBe("agent-x");
195
+ });
196
+
197
+ // Test 3: Event rooms are independent
198
+ test("event for run-A is not delivered to run-B subscriber", async () => {
199
+ const server = await startWithBroadcaster();
200
+
201
+ const wsA = new WebSocket(`ws://127.0.0.1:${server.port}/ws?run=run-A`);
202
+ const wsB = new WebSocket(`ws://127.0.0.1:${server.port}/ws?run=run-B`);
203
+ wsConnections.push(wsA, wsB);
204
+ await Promise.all([waitForOpen(wsA), waitForOpen(wsB)]);
205
+
206
+ const msgsB: unknown[] = [];
207
+ wsB.addEventListener("message", (e: MessageEvent) => {
208
+ msgsB.push(JSON.parse(e.data as string));
209
+ });
210
+
211
+ const pendingA = collectMessages(wsA, 1, 1000);
212
+ insertEvent(eventsDb, "agent-x", "run-A", "tool_start");
213
+
214
+ await pendingA;
215
+ // Give run-B subscriber extra time to (not) receive anything
216
+ await new Promise((r) => setTimeout(r, 200));
217
+ expect(msgsB.length).toBe(0);
218
+ });
219
+
220
+ // Test 4: Mail fans out to both agent:<from> and agent:<to>
221
+ test("mail insert fans out to both agent:<from> and agent:<to>", async () => {
222
+ const server = await startWithBroadcaster();
223
+
224
+ const wsFrom = new WebSocket(`ws://127.0.0.1:${server.port}/ws?agent=sender`);
225
+ const wsTo = new WebSocket(`ws://127.0.0.1:${server.port}/ws?agent=recipient`);
226
+ wsConnections.push(wsFrom, wsTo);
227
+ await Promise.all([waitForOpen(wsFrom), waitForOpen(wsTo)]);
228
+
229
+ const pendingFrom = collectMessages(wsFrom, 1, 1000);
230
+ const pendingTo = collectMessages(wsTo, 1, 1000);
231
+
232
+ insertMail(mailDb, "msg-test-1", "sender", "recipient");
233
+
234
+ const [msgsFrom, msgsTo] = await Promise.all([pendingFrom, pendingTo]);
235
+
236
+ expect(msgsFrom.length).toBe(1);
237
+ const frameFrom = msgsFrom[0] as Record<string, unknown>;
238
+ expect(frameFrom.type).toBe("mail");
239
+
240
+ expect(msgsTo.length).toBe(1);
241
+ const frameTo = msgsTo[0] as Record<string, unknown>;
242
+ expect(frameTo.type).toBe("mail");
243
+ const payload = frameTo.payload as Record<string, unknown>;
244
+ const message = payload.message as Record<string, unknown>;
245
+ expect(message.from).toBe("sender");
246
+ expect(message.to).toBe("recipient");
247
+ });
248
+
249
+ // Test 5: Text events batch; non-text events don't
250
+ test("text events in rapid succession arrive batched; non-text arrive un-batched", async () => {
251
+ const server = await startWithBroadcaster();
252
+ const ws = new WebSocket(`ws://127.0.0.1:${server.port}/ws?agent=batch-agent`);
253
+ wsConnections.push(ws);
254
+ await waitForOpen(ws);
255
+
256
+ // Collect messages over a window large enough for batch flush
257
+ const received: unknown[] = [];
258
+ ws.addEventListener("message", (e: MessageEvent) => {
259
+ received.push(JSON.parse(e.data as string));
260
+ });
261
+
262
+ // Insert 3 text events before any poll fires
263
+ for (let i = 0; i < 3; i++) {
264
+ insertEvent(eventsDb, "batch-agent", null, "text", null, null);
265
+ }
266
+ // Wait for poll + batch window
267
+ await new Promise((r) => setTimeout(r, POLL_MS + 300));
268
+
269
+ // Should have received exactly 1 batched message
270
+ const textFrames = received.filter((m) => {
271
+ const f = m as Record<string, unknown>;
272
+ const p = f.payload as Record<string, unknown>;
273
+ return f.type === "event" && "batched" in p;
274
+ });
275
+ expect(textFrames.length).toBe(1);
276
+ const batched = (textFrames[0] as Record<string, unknown>).payload as Record<string, unknown>;
277
+ expect(batched.batched).toBe(true);
278
+ expect(Array.isArray(batched.events)).toBe(true);
279
+ expect((batched.events as unknown[]).length).toBe(3);
280
+
281
+ // Now test non-text: reset and insert a tool_start
282
+ received.length = 0;
283
+ insertEvent(eventsDb, "batch-agent", null, "tool_start");
284
+ await new Promise((r) => setTimeout(r, POLL_MS + 50));
285
+
286
+ const nonTextFrames = received.filter((m) => {
287
+ const f = m as Record<string, unknown>;
288
+ const p = f.payload as Record<string, unknown>;
289
+ return f.type === "event" && !("batched" in p);
290
+ });
291
+ expect(nonTextFrames.length).toBe(1);
292
+ });
293
+
294
+ // Test 6: Disconnect removes socket from registry
295
+ test("disconnecting removes socket from all rooms (registry empty after close)", async () => {
296
+ const server = await startWithBroadcaster();
297
+ const ws = new WebSocket(`ws://127.0.0.1:${server.port}/ws?run=cleanup-run`);
298
+ wsConnections.push(ws);
299
+ await waitForOpen(ws);
300
+
301
+ // Room should now contain the socket
302
+ expect(_getRoomCount()).toBe(1);
303
+
304
+ // Disconnect
305
+ const closed = new Promise<void>((resolve) => {
306
+ ws.addEventListener("close", () => resolve());
307
+ });
308
+ ws.close();
309
+ await closed;
310
+ // Give server-side close callback time to fire
311
+ await new Promise((r) => setTimeout(r, 100));
312
+
313
+ expect(_getRoomCount()).toBe(0);
314
+ });
315
+
316
+ // Test 6b: ?mail=true upgrades and an inserted mail row is delivered to subscriber
317
+ test("?mail=true upgrades and inserted mail row is delivered to subscriber", async () => {
318
+ const server = await startWithBroadcaster();
319
+ const ws = new WebSocket(`ws://127.0.0.1:${server.port}/ws?mail=true`);
320
+ wsConnections.push(ws);
321
+ await waitForOpen(ws);
322
+
323
+ const pending = collectMessages(ws, 1, 1000);
324
+ insertMail(mailDb, "msg-mail-room-1", "alpha", "beta");
325
+
326
+ const msgs = await pending;
327
+ expect(msgs.length).toBe(1);
328
+ const frame = msgs[0] as Record<string, unknown>;
329
+ expect(frame.type).toBe("mail");
330
+ const payload = frame.payload as Record<string, unknown>;
331
+ const message = payload.message as Record<string, unknown>;
332
+ expect(message.from).toBe("alpha");
333
+ expect(message.to).toBe("beta");
334
+ });
335
+
336
+ // Test 7: /ws with no run/agent → 400 JSON
337
+ test("/ws with no run or agent query param returns 400 JSON envelope", async () => {
338
+ const server = await startWithBroadcaster();
339
+ const res = await fetch(`http://127.0.0.1:${server.port}/ws`);
340
+ expect(res.status).toBe(400);
341
+ const body = (await res.json()) as Record<string, unknown>;
342
+ expect(body.success).toBe(false);
343
+ expect(typeof body.error).toBe("string");
344
+ });
345
+
346
+ // Test 8: /ws with no handler → 404 JSON
347
+ test("/ws with no handler installed returns 404 JSON envelope", async () => {
348
+ // No installBroadcaster called — no WsHandler registered
349
+ const origCwd = process.cwd;
350
+ process.cwd = () => tempDir;
351
+ const server = await createServeServer({ port: 0, host: "127.0.0.1" });
352
+ process.cwd = origCwd;
353
+ servers.push(server);
354
+
355
+ const res = await fetch(`http://127.0.0.1:${server.port}/ws`);
356
+ expect(res.status).toBe(404);
357
+ const body = (await res.json()) as Record<string, unknown>;
358
+ expect(body.success).toBe(false);
359
+ expect(typeof body.error).toBe("string");
360
+ });
361
+ });