@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,565 @@
1
+ /**
2
+ * CLI command: ov serve [--port <n>] [--host <addr>]
3
+ *
4
+ * Starts an HTTP server backed by Bun.serve. Serves:
5
+ * - /healthz — JSON health envelope (always available)
6
+ * - /api/* — REST handlers registered via registerApiHandler()
7
+ * - /ws — WebSocket upgrade registered via registerWsHandler()
8
+ * - everything else — static files from ui/dist/ with SPA fallback to index.html
9
+ *
10
+ * Route registration is intentionally modular: future streams add REST/WebSocket
11
+ * support by calling the exported register*() helpers — no changes to this file needed.
12
+ */
13
+
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { Command } from "commander";
17
+ import { startTurnRunnerMailLoop, type TurnRunnerFn } from "../agents/headless-mail-injector.ts";
18
+ import { createManifestLoader } from "../agents/manifest.ts";
19
+ import { runTurn } from "../agents/turn-runner.ts";
20
+ import { buildRunTurnOptsFactory, isSpawnPerTurnAgent } from "../agents/turn-runner-dispatch.ts";
21
+ import { loadConfig } from "../config.ts";
22
+ import { ValidationError } from "../errors.ts";
23
+ import { apiJson, jsonError, jsonOutput } from "../json.ts";
24
+ import { printError, printSuccess } from "../logging/color.ts";
25
+ import { openSessionStore } from "../sessions/compat.ts";
26
+ import type { AgentManifest, OverstoryConfig } from "../types.ts";
27
+ import { ensureUiBuild } from "./serve/build.ts";
28
+ import { type DevServerHandle, startDevServer } from "./serve/dev.ts";
29
+ import { type RestApiDeps, registerRestApi } from "./serve/rest.ts";
30
+ import { serveStatic } from "./serve/static.ts";
31
+ import { installBroadcaster } from "./serve/ws.ts";
32
+
33
+ /**
34
+ * Default TCP port for `ov serve`. 8080 collides with Colima's SSH mux tunnel
35
+ * (and Tomcat/Jenkins/Docker dev proxies); the kernel routes to *:8080 vs.
36
+ * 127.0.0.1:8080 inconsistently, so users saw foreign error JSON instead of
37
+ * the overstory UI (overstory-eaba). 7321 is unassigned and easy to type.
38
+ */
39
+ export const DEFAULT_SERVE_PORT = 7321;
40
+
41
+ // === Extensible route registry ===
42
+
43
+ /** Handler for /api/* routes. Return null to fall through to the next handler. */
44
+ export type ApiHandler = (req: Request) => Response | Promise<Response> | null;
45
+
46
+ /** Handler for WebSocket upgrade on /ws. */
47
+ export type WsHandler = {
48
+ open?: (ws: ServerWebSocket) => void;
49
+ message?: (ws: ServerWebSocket, message: string | Buffer) => void;
50
+ close?: (ws: ServerWebSocket, code: number, reason: string) => void;
51
+ /** Return upgrade data (passed to ws.data) or null to reject with HTTP 400. */
52
+ getUpgradeData?: (req: Request) => unknown | null;
53
+ };
54
+
55
+ // ServerWebSocket is a Bun built-in — use the global type alias
56
+ type ServerWebSocket = import("bun").ServerWebSocket<unknown>;
57
+
58
+ const _apiHandlers: ApiHandler[] = [];
59
+ let _wsHandler: WsHandler | undefined;
60
+
61
+ /**
62
+ * Register an API route handler for requests under /api/*.
63
+ * Handlers are tried in registration order; first non-null response wins.
64
+ * Intended for use by future streams (REST endpoints, etc.).
65
+ */
66
+ export function registerApiHandler(handler: ApiHandler): void {
67
+ _apiHandlers.push(handler);
68
+ }
69
+
70
+ /**
71
+ * Register the WebSocket handler for /ws upgrades.
72
+ * Only one handler may be active; subsequent calls replace the previous one.
73
+ * Intended for use by the WebSocket broadcaster stream.
74
+ */
75
+ export function registerWsHandler(handler: WsHandler): void {
76
+ _wsHandler = handler;
77
+ }
78
+
79
+ /** Reset registered handlers (test isolation only). */
80
+ export function _resetHandlers(): void {
81
+ _apiHandlers.length = 0;
82
+ _wsHandler = undefined;
83
+ }
84
+
85
+ // === Core server logic ===
86
+
87
+ export interface ServeOptions {
88
+ port?: number;
89
+ host?: string;
90
+ json?: boolean;
91
+ /** When true, also start the Vite-style dev UI server (HMR, /api+/ws proxy). */
92
+ dev?: boolean;
93
+ /** Dev UI port. Ignored unless dev is true. Default 3000. */
94
+ devPort?: number;
95
+ }
96
+
97
+ /** Dependencies injectable for testing. */
98
+ export interface ServeDeps {
99
+ _loadConfig?: typeof loadConfig;
100
+ _existsSync?: typeof existsSync;
101
+ _readFile?: (path: string) => Promise<Uint8Array>;
102
+ /** REST store deps. Pass false to skip REST registration (test isolation). */
103
+ _restDeps?: RestApiDeps | false;
104
+ _ensureUiBuild?: typeof ensureUiBuild;
105
+ _startDevServer?: typeof startDevServer;
106
+ /** Skip the auto-build step entirely (test isolation). */
107
+ _skipAutoBuild?: boolean;
108
+ /**
109
+ * Override ui/dist resolution. Default prefers `<projectRoot>/ui/dist`,
110
+ * falling back to the prebuilt assets shipped inside the npm package. Tests
111
+ * pass a stub returning a non-existent path to force the 503 branch in
112
+ * serveStatic without depending on the dev repo's package layout.
113
+ */
114
+ _resolveUiDistPath?: (projectRoot: string) => string;
115
+ }
116
+
117
+ /**
118
+ * Resolve the directory containing built UI assets. Prefers the project's own
119
+ * `ui/dist` (so overstory dev builds and project-local UI overrides win), and
120
+ * falls back to the prebuilt `ui/dist` shipped inside @os-eco/overstory-cli
121
+ * for production installs that have no `ui/` workspace (overstory-916d).
122
+ */
123
+ export function resolveUiDistPath(
124
+ projectRoot: string,
125
+ _exists: typeof existsSync = existsSync,
126
+ ): string {
127
+ const projectDist = join(projectRoot, "ui", "dist");
128
+ if (_exists(projectDist)) return projectDist;
129
+ return new URL("../../ui/dist", import.meta.url).pathname;
130
+ }
131
+
132
+ /** Read the package version once at module load to avoid circular imports with index.ts. */
133
+ const _pkgVersion = (): string => {
134
+ try {
135
+ const raw = readFileSync(new URL("../../package.json", import.meta.url).pathname, "utf-8");
136
+ return (JSON.parse(raw) as { version: string }).version;
137
+ } catch {
138
+ return "unknown";
139
+ }
140
+ };
141
+ const SERVE_VERSION = _pkgVersion();
142
+
143
+ /**
144
+ * Build and return a Bun server instance without binding to process signals.
145
+ * Used by tests to control lifecycle directly.
146
+ */
147
+ export async function createServeServer(
148
+ opts: ServeOptions,
149
+ deps: ServeDeps = {},
150
+ ): Promise<ReturnType<typeof Bun.serve>> {
151
+ const _cfg = deps._loadConfig ?? loadConfig;
152
+ const _exists = deps._existsSync ?? existsSync;
153
+
154
+ const cwd = process.cwd();
155
+ const config = await _cfg(cwd);
156
+
157
+ const port = opts.port ?? DEFAULT_SERVE_PORT;
158
+ const hostname = opts.host ?? "127.0.0.1";
159
+ const _resolveUiDist = deps._resolveUiDistPath ?? resolveUiDistPath;
160
+ const uiDistPath = _resolveUiDist(config.project.root);
161
+ const startTime = performance.now();
162
+
163
+ // Register REST handlers before Bun.serve() — skip only for test isolation
164
+ if (deps._restDeps !== false) {
165
+ registerRestApi({ _projectRoot: config.project.root, ...(deps._restDeps ?? {}) });
166
+ }
167
+
168
+ const server = Bun.serve({
169
+ port,
170
+ hostname,
171
+ fetch: async (req: Request, srv: ReturnType<typeof Bun.serve>): Promise<Response> => {
172
+ const url = new URL(req.url);
173
+ const path = url.pathname;
174
+
175
+ // /healthz — always handled here
176
+ if (path === "/healthz") {
177
+ return apiJson({
178
+ status: "ok",
179
+ uptimeMs: Math.round(performance.now() - startTime),
180
+ version: SERVE_VERSION,
181
+ });
182
+ }
183
+
184
+ // /ws — WebSocket upgrade
185
+ if (path === "/ws") {
186
+ if (_wsHandler === undefined) {
187
+ return new Response(
188
+ JSON.stringify({ success: false, command: "serve", error: "WebSocket not available" }),
189
+ { status: 404, headers: { "Content-Type": "application/json" } },
190
+ );
191
+ }
192
+ const upgradeData = _wsHandler.getUpgradeData?.(req);
193
+ if (upgradeData === null) {
194
+ return new Response(
195
+ JSON.stringify({
196
+ success: false,
197
+ command: "serve",
198
+ error: "Missing run or agent query parameter",
199
+ }),
200
+ { status: 400, headers: { "Content-Type": "application/json" } },
201
+ );
202
+ }
203
+ const upgraded = srv.upgrade(req, { data: upgradeData });
204
+ if (upgraded) {
205
+ return new Response(null, { status: 101 });
206
+ }
207
+ return new Response(
208
+ JSON.stringify({ success: false, command: "serve", error: "WebSocket upgrade failed" }),
209
+ { status: 500, headers: { "Content-Type": "application/json" } },
210
+ );
211
+ }
212
+
213
+ // /api/* — delegated to registered API handlers
214
+ if (path.startsWith("/api/")) {
215
+ for (const handler of _apiHandlers) {
216
+ const res = await handler(req);
217
+ if (res !== null) {
218
+ return res;
219
+ }
220
+ }
221
+ return new Response(
222
+ JSON.stringify({ success: false, command: "serve", error: "Not found" }),
223
+ {
224
+ status: 404,
225
+ headers: { "Content-Type": "application/json" },
226
+ },
227
+ );
228
+ }
229
+
230
+ // Static files from ui/dist/ with SPA fallback and path-traversal guard
231
+ return serveStatic(path, uiDistPath, _exists);
232
+ },
233
+ websocket: {
234
+ open(ws) {
235
+ _wsHandler?.open?.(ws);
236
+ },
237
+ message(ws, message) {
238
+ _wsHandler?.message?.(ws, message as string | Buffer);
239
+ },
240
+ close(ws, code, reason) {
241
+ _wsHandler?.close?.(ws, code, reason);
242
+ },
243
+ },
244
+ });
245
+
246
+ return server;
247
+ }
248
+
249
+ /**
250
+ * Install per-agent mail injection loops, driven by filesystem discovery of
251
+ * stdin FIFOs.
252
+ *
253
+ * Replaces the UserPromptSubmit hook for headless Claude agents: each agent
254
+ * spawned by `ov sling` mkfifos a `{overstoryDir}/agents/{name}/stdin.fifo`,
255
+ * and `ov serve` watches that directory. For every FIFO it sees, the server
256
+ * starts a polling loop that opens the FIFO, writes any unread mail as a
257
+ * stream-json user turn, then closes. Loops are torn down when the FIFO file
258
+ * disappears (agent terminated + cleanup ran), when the writer reports
259
+ * "no-reader" (agent died but cleanup hasn't run), or on graceful shutdown.
260
+ *
261
+ * The cross-process design — file-on-disk vs in-memory registry — is essential
262
+ * because `ov sling` and `ov serve` are separate processes. The earlier
263
+ * connection-registry design only worked when serve and sling shared a process,
264
+ * which is never the case in production. See overstory-41eb.
265
+ */
266
+ /** Optional spawn-per-turn dispatch context for `installMailInjectors`. */
267
+ export interface MailInjectorDispatchDeps {
268
+ config: OverstoryConfig;
269
+ manifest: AgentManifest;
270
+ /** Test injection: replaces `runTurn`. */
271
+ _runTurnFn?: TurnRunnerFn;
272
+ }
273
+
274
+ /**
275
+ * Attempt to start the spawn-per-turn dispatcher for one agent. Returns the
276
+ * stop function on success, or null when the agent is not eligible (capability
277
+ * gate, flag off, terminal state, missing session row, or runtime can't drive
278
+ * a direct spawn).
279
+ */
280
+ function tryInstallTurnRunnerLoop(
281
+ agentName: string,
282
+ mailDbPath: string,
283
+ overstoryDir: string,
284
+ dispatch: MailInjectorDispatchDeps,
285
+ ): (() => void) | null {
286
+ const { store } = openSessionStore(overstoryDir);
287
+ let session: ReturnType<typeof store.getByName>;
288
+ try {
289
+ session = store.getByName(agentName);
290
+ } finally {
291
+ store.close();
292
+ }
293
+ if (!session) return null;
294
+
295
+ let factory: ReturnType<typeof buildRunTurnOptsFactory>;
296
+ try {
297
+ factory = buildRunTurnOptsFactory({
298
+ session,
299
+ config: dispatch.config,
300
+ manifest: dispatch.manifest,
301
+ overstoryDir,
302
+ });
303
+ } catch {
304
+ return null;
305
+ }
306
+
307
+ if (!isSpawnPerTurnAgent(session, dispatch.config, factory.runtime)) return null;
308
+
309
+ const runTurnFn = dispatch._runTurnFn ?? runTurn;
310
+ // Per-tick liveness check: re-read SessionStore on every poll so that
311
+ // `ov stop` (which writes state=completed within ~milliseconds) is observed
312
+ // before the 5s rescan reaps the loop. Without this guard, the 2s tick
313
+ // could dispatch a fresh runTurn against the stopped agent during the
314
+ // rescan window (overstory-eb7c).
315
+ const isAgentLive = (): boolean => {
316
+ const { store: liveStore } = openSessionStore(overstoryDir);
317
+ try {
318
+ const live = liveStore.getByName(agentName);
319
+ if (!live) return false;
320
+ return live.state !== "completed" && live.state !== "zombie";
321
+ } finally {
322
+ liveStore.close();
323
+ }
324
+ };
325
+ return startTurnRunnerMailLoop(
326
+ agentName,
327
+ factory.build,
328
+ runTurnFn,
329
+ mailDbPath,
330
+ undefined,
331
+ isAgentLive,
332
+ );
333
+ }
334
+
335
+ /**
336
+ * Install per-agent mail injection loops driven by the spawn-per-turn engine.
337
+ *
338
+ * Discovers agents from SessionStore (rather than from a per-agent FIFO file
339
+ * — Phase 3 deletes the FIFO infrastructure). Sessions in non-terminal state
340
+ * with a task-scoped capability get a `runTurn`-driven mail dispatcher; loops
341
+ * auto-stop when the session transitions to `completed`/`zombie`.
342
+ *
343
+ * `dispatch` is required: under Phase 3 spawn-per-turn is the only mail
344
+ * injection mechanism for headless Claude. When called without dispatch (e.g.
345
+ * in tests that don't exercise mail), the function still returns a no-op stop.
346
+ */
347
+ export function installMailInjectors(
348
+ mailDbPath: string,
349
+ overstoryDir: string,
350
+ dispatch?: MailInjectorDispatchDeps,
351
+ ): () => void {
352
+ const activeLoops = new Map<string, () => void>();
353
+
354
+ if (dispatch === undefined) {
355
+ // No manifest available — no spawn-per-turn dispatch possible. Return a
356
+ // no-op stop so callers can wire shutdown unconditionally.
357
+ return function noopStopMailInjectors(): void {};
358
+ }
359
+
360
+ const startLoopFor = (agentName: string): void => {
361
+ if (activeLoops.has(agentName)) return;
362
+ const turnLoop = tryInstallTurnRunnerLoop(agentName, mailDbPath, overstoryDir, dispatch);
363
+ if (turnLoop === null) return;
364
+ activeLoops.set(agentName, () => {
365
+ turnLoop();
366
+ activeLoops.delete(agentName);
367
+ });
368
+ };
369
+
370
+ const stopLoopFor = (agentName: string): void => {
371
+ activeLoops.get(agentName)?.();
372
+ };
373
+
374
+ // Discover non-terminal agents from SessionStore. Each rescan re-checks
375
+ // every agent's state so loops auto-stop on completed/zombie.
376
+ const scan = (): void => {
377
+ const { store } = openSessionStore(overstoryDir);
378
+ let sessions: ReturnType<typeof store.getAll>;
379
+ try {
380
+ sessions = store.getAll();
381
+ } finally {
382
+ store.close();
383
+ }
384
+ const liveNames = new Set<string>();
385
+ for (const session of sessions) {
386
+ if (session.state === "completed" || session.state === "zombie") continue;
387
+ liveNames.add(session.agentName);
388
+ startLoopFor(session.agentName);
389
+ }
390
+ // Reap loops whose sessions transitioned to a terminal state.
391
+ for (const name of [...activeLoops.keys()]) {
392
+ if (!liveNames.has(name)) {
393
+ stopLoopFor(name);
394
+ }
395
+ }
396
+ };
397
+ scan();
398
+
399
+ const rescanTimer = setInterval(scan, 5000);
400
+
401
+ return function stopMailInjectors(): void {
402
+ clearInterval(rescanTimer);
403
+ for (const stop of [...activeLoops.values()]) stop();
404
+ activeLoops.clear();
405
+ };
406
+ }
407
+
408
+ /**
409
+ * Core implementation for `ov serve`. Starts the server and blocks until
410
+ * SIGINT/SIGTERM. Handles graceful shutdown.
411
+ */
412
+ export async function runServe(opts: ServeOptions, deps: ServeDeps = {}): Promise<void> {
413
+ const _cfg = deps._loadConfig ?? loadConfig;
414
+ const config = await _cfg(process.cwd());
415
+
416
+ const overstoryDir = join(config.project.root, ".overstory");
417
+ const mailDbPath = join(overstoryDir, "mail.db");
418
+ const uiDir = join(config.project.root, "ui");
419
+
420
+ // Production mode: ensure ui/dist is current before binding the port.
421
+ // In dev mode, skip the prebuilt assets entirely — the dev server owns
422
+ // the UI surface and reads ui/src directly.
423
+ const _ensureUi = deps._ensureUiBuild ?? ensureUiBuild;
424
+ if (!opts.dev && deps._skipAutoBuild !== true) {
425
+ await _ensureUi({ uiDir });
426
+ }
427
+
428
+ // Install broadcaster before Bun.serve so handler is ready for the first request
429
+ const stopBroadcaster = installBroadcaster({
430
+ eventsDbPath: join(overstoryDir, "events.db"),
431
+ mailDbPath,
432
+ });
433
+
434
+ // Install per-agent mail injection loops (UserPromptSubmit hook equivalent
435
+ // for headless Claude agents). Discovers task-scoped agents from
436
+ // SessionStore and dispatches each batch of unread mail through `runTurn`,
437
+ // which spawns a fresh claude with --resume per turn (Phase 3 spawn-per-turn).
438
+ const manifestLoader = createManifestLoader(
439
+ join(config.project.root, config.agents.manifestPath),
440
+ join(config.project.root, config.agents.baseDir),
441
+ );
442
+ let manifest: AgentManifest | undefined;
443
+ try {
444
+ manifest = await manifestLoader.load();
445
+ } catch {
446
+ // Non-fatal: missing manifest just means the spawn-per-turn dispatcher
447
+ // stays disabled and every agent uses the legacy FIFO loop.
448
+ manifest = undefined;
449
+ }
450
+ const stopMailInjectors = installMailInjectors(
451
+ mailDbPath,
452
+ overstoryDir,
453
+ manifest ? { config, manifest } : undefined,
454
+ );
455
+
456
+ const server = await createServeServer(opts, deps);
457
+
458
+ let dev: DevServerHandle | undefined;
459
+ if (opts.dev) {
460
+ const _startDev = deps._startDevServer ?? startDevServer;
461
+ dev = await _startDev({
462
+ uiDir,
463
+ port: opts.devPort ?? 3000,
464
+ apiPort: server.port,
465
+ apiHost: server.hostname,
466
+ });
467
+ }
468
+
469
+ const useJson = opts.json ?? false;
470
+ const apiUrl = `http://${server.hostname}:${server.port}`;
471
+ if (useJson) {
472
+ jsonOutput("serve", {
473
+ status: "started",
474
+ port: server.port,
475
+ hostname: server.hostname,
476
+ url: apiUrl,
477
+ ...(dev ? { devUrl: `http://127.0.0.1:${dev.port}` } : {}),
478
+ });
479
+ } else {
480
+ printSuccess(`ov serve listening on ${apiUrl}`);
481
+ if (dev) {
482
+ printSuccess(`ov serve dev UI on http://127.0.0.1:${dev.port}`);
483
+ }
484
+ }
485
+
486
+ // Graceful shutdown handler
487
+ const shutdown = (): void => {
488
+ if (!useJson) {
489
+ process.stdout.write("\nShutting down...\n");
490
+ }
491
+ // Stop the dev server first so the upstream WebSocket pump drains
492
+ // before we tear down the broadcaster + main server.
493
+ const stopDev = dev ? dev.stop() : Promise.resolve();
494
+ stopDev
495
+ .catch(() => {
496
+ // Best-effort stop — surface nothing on failure.
497
+ })
498
+ .finally(() => {
499
+ stopMailInjectors();
500
+ stopBroadcaster();
501
+ server.stop(true);
502
+ process.exit(0);
503
+ });
504
+ };
505
+
506
+ process.on("SIGINT", shutdown);
507
+ process.on("SIGTERM", shutdown);
508
+
509
+ // Block indefinitely — the server keeps the process alive via Bun's event loop
510
+ await new Promise<void>(() => {});
511
+ }
512
+
513
+ /**
514
+ * Create the Commander command for `ov serve`.
515
+ */
516
+ export function createServeCommand(): Command {
517
+ return new Command("serve")
518
+ .description("Start the HTTP server (static UI + /healthz + /api/* + /ws)")
519
+ .option("--port <n>", "TCP port to listen on", String(DEFAULT_SERVE_PORT))
520
+ .option("--host <addr>", "Host/address to bind", "127.0.0.1")
521
+ .option("--dev", "Also start the dev UI server with HMR + API/WS proxy")
522
+ .option("--dev-port <n>", "Dev UI port (only with --dev)", "3000")
523
+ .option("--json", "Output startup info as JSON")
524
+ .action(
525
+ async (opts: {
526
+ port?: string;
527
+ host?: string;
528
+ dev?: boolean;
529
+ devPort?: string;
530
+ json?: boolean;
531
+ }) => {
532
+ const port = opts.port !== undefined ? Number.parseInt(opts.port, 10) : DEFAULT_SERVE_PORT;
533
+ const devPort = opts.devPort !== undefined ? Number.parseInt(opts.devPort, 10) : 3000;
534
+ try {
535
+ if (Number.isNaN(port) || port < 1 || port > 65535) {
536
+ throw new ValidationError(`Invalid port: ${opts.port ?? "undefined"}`, {
537
+ field: "port",
538
+ value: opts.port,
539
+ });
540
+ }
541
+ if (Number.isNaN(devPort) || devPort < 1 || devPort > 65535) {
542
+ throw new ValidationError(`Invalid dev port: ${opts.devPort ?? "undefined"}`, {
543
+ field: "devPort",
544
+ value: opts.devPort,
545
+ });
546
+ }
547
+ await runServe({
548
+ port,
549
+ host: opts.host ?? "127.0.0.1",
550
+ json: opts.json,
551
+ dev: opts.dev ?? false,
552
+ devPort,
553
+ });
554
+ } catch (err: unknown) {
555
+ const msg = err instanceof Error ? err.message : String(err);
556
+ if (opts.json) {
557
+ jsonError("serve", msg);
558
+ } else {
559
+ printError(`ov serve failed: ${msg}`);
560
+ }
561
+ process.exitCode = 1;
562
+ }
563
+ },
564
+ );
565
+ }
@@ -4,7 +4,7 @@ import { mkdtemp } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { resolveModel, resolveProviderEnv } from "../agents/manifest.ts";
7
- import { HierarchyError } from "../errors.ts";
7
+ import { HierarchyError, ValidationError } from "../errors.ts";
8
8
  import { ClaudeRuntime } from "../runtimes/claude.ts";
9
9
  import { getRuntime } from "../runtimes/registry.ts";
10
10
  import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
@@ -25,7 +25,9 @@ import {
25
25
  getSharedWritableDirs,
26
26
  inferDomainsFromFiles,
27
27
  isRunningAsRoot,
28
+ isTaskWorkable,
28
29
  parentHasScouts,
30
+ resolveUseHeadless,
29
31
  shouldShowScoutWarning,
30
32
  validateHierarchy,
31
33
  } from "./sling.ts";
@@ -766,6 +768,25 @@ function makeLeadSession(
766
768
  return { agentName, taskId, capability };
767
769
  }
768
770
 
771
+ describe("isTaskWorkable", () => {
772
+ test("accepts open and in_progress without recover", () => {
773
+ expect(isTaskWorkable("open", false)).toBe(true);
774
+ expect(isTaskWorkable("in_progress", false)).toBe(true);
775
+ });
776
+
777
+ test("rejects closed and other terminal statuses without recover", () => {
778
+ expect(isTaskWorkable("closed", false)).toBe(false);
779
+ expect(isTaskWorkable("cancelled", false)).toBe(false);
780
+ expect(isTaskWorkable("done", false)).toBe(false);
781
+ });
782
+
783
+ test("accepts any status when recover is true", () => {
784
+ expect(isTaskWorkable("closed", true)).toBe(true);
785
+ expect(isTaskWorkable("cancelled", true)).toBe(true);
786
+ expect(isTaskWorkable("open", true)).toBe(true);
787
+ });
788
+ });
789
+
769
790
  describe("checkDuplicateLead", () => {
770
791
  test("returns lead agent name when an active lead exists for the task", () => {
771
792
  const sessions = [
@@ -1038,6 +1059,18 @@ describe("sling provider env injection building blocks", () => {
1038
1059
  expect(combined.OVERSTORY_TASK_ID).toBe("overstory-1234");
1039
1060
  });
1040
1061
 
1062
+ test("env dict includes OVERSTORY_PROJECT_ROOT", () => {
1063
+ const env = { MODEL_KEY: "value" };
1064
+ const combined = {
1065
+ ...env,
1066
+ OVERSTORY_AGENT_NAME: "test-builder",
1067
+ OVERSTORY_WORKTREE_PATH: "/path/to/wt",
1068
+ OVERSTORY_TASK_ID: "task-1",
1069
+ OVERSTORY_PROJECT_ROOT: "/path/to/project",
1070
+ };
1071
+ expect(combined.OVERSTORY_PROJECT_ROOT).toBe("/path/to/project");
1072
+ });
1073
+
1041
1074
  test("resolveModel returns no env for native anthropic provider", () => {
1042
1075
  const config = makeConfig({ builder: "sonnet" }, { anthropic: { type: "native" } });
1043
1076
  const manifest = makeManifest();
@@ -1393,3 +1426,54 @@ describe("getCurrentBranch", () => {
1393
1426
  }
1394
1427
  });
1395
1428
  });
1429
+
1430
+ describe("resolveUseHeadless", () => {
1431
+ const claudeLike = { id: "claude", buildDirectSpawn: () => [] as string[] };
1432
+ const claudeNoSpawn = { id: "claude" };
1433
+ const saplingLike = {
1434
+ id: "sapling",
1435
+ headless: true as const,
1436
+ buildDirectSpawn: () => [] as string[],
1437
+ };
1438
+ const codexLike = { id: "codex" };
1439
+ const baseConfig = {} as OverstoryConfig;
1440
+ const headlessByDefaultConfig = {
1441
+ runtime: { default: "claude", claudeHeadlessByDefault: true },
1442
+ } as unknown as OverstoryConfig;
1443
+
1444
+ test("statically headless runtime returns true regardless of flag", () => {
1445
+ expect(resolveUseHeadless(saplingLike, undefined, baseConfig)).toBe(true);
1446
+ });
1447
+
1448
+ test("claude + no flag + base config returns false (default tmux)", () => {
1449
+ expect(resolveUseHeadless(claudeLike, undefined, baseConfig)).toBe(false);
1450
+ });
1451
+
1452
+ test("claude + no flag + claudeHeadlessByDefault:true returns true", () => {
1453
+ expect(resolveUseHeadless(claudeLike, undefined, headlessByDefaultConfig)).toBe(true);
1454
+ });
1455
+
1456
+ test("claude + flag:true + base config returns true", () => {
1457
+ expect(resolveUseHeadless(claudeLike, true, baseConfig)).toBe(true);
1458
+ });
1459
+
1460
+ test("claude + flag:false + claudeHeadlessByDefault:true returns false (flag wins)", () => {
1461
+ expect(resolveUseHeadless(claudeLike, false, headlessByDefaultConfig)).toBe(false);
1462
+ });
1463
+
1464
+ test("claude without buildDirectSpawn + flag:true throws ValidationError", () => {
1465
+ expect(() => resolveUseHeadless(claudeNoSpawn, true, baseConfig)).toThrow(ValidationError);
1466
+ });
1467
+
1468
+ test("codex + claudeHeadlessByDefault:true returns false (config knob is Claude-only)", () => {
1469
+ expect(resolveUseHeadless(codexLike, undefined, headlessByDefaultConfig)).toBe(false);
1470
+ });
1471
+
1472
+ test("codex + flag:true throws ValidationError (no buildDirectSpawn)", () => {
1473
+ expect(() => resolveUseHeadless(codexLike, true, baseConfig)).toThrow(ValidationError);
1474
+ });
1475
+
1476
+ test("sapling + flag:false returns true (statically headless wins over flag)", () => {
1477
+ expect(resolveUseHeadless(saplingLike, false, baseConfig)).toBe(true);
1478
+ });
1479
+ });