@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.
- package/README.md +49 -18
- package/agents/builder.md +9 -8
- package/agents/coordinator.md +6 -6
- package/agents/lead.md +98 -82
- package/agents/merger.md +25 -14
- package/agents/reviewer.md +22 -16
- package/agents/scout.md +17 -12
- package/package.json +6 -3
- package/src/agents/capabilities.test.ts +85 -0
- package/src/agents/capabilities.ts +125 -0
- package/src/agents/headless-mail-injector.test.ts +448 -0
- package/src/agents/headless-mail-injector.ts +211 -0
- package/src/agents/headless-prompt.test.ts +102 -0
- package/src/agents/headless-prompt.ts +68 -0
- package/src/agents/hooks-deployer.test.ts +514 -14
- package/src/agents/hooks-deployer.ts +141 -0
- package/src/agents/overlay.test.ts +4 -4
- package/src/agents/overlay.ts +30 -8
- package/src/agents/turn-lock.test.ts +181 -0
- package/src/agents/turn-lock.ts +235 -0
- package/src/agents/turn-runner-dispatch.test.ts +182 -0
- package/src/agents/turn-runner-dispatch.ts +105 -0
- package/src/agents/turn-runner.test.ts +1450 -0
- package/src/agents/turn-runner.ts +1166 -0
- package/src/commands/clean.ts +56 -1
- package/src/commands/completions.test.ts +4 -1
- package/src/commands/coordinator.test.ts +127 -0
- package/src/commands/coordinator.ts +205 -6
- package/src/commands/dashboard.test.ts +188 -0
- package/src/commands/dashboard.ts +13 -3
- package/src/commands/doctor.ts +94 -77
- package/src/commands/group.test.ts +94 -0
- package/src/commands/group.ts +49 -20
- package/src/commands/init.test.ts +8 -0
- package/src/commands/init.ts +8 -1
- package/src/commands/log.test.ts +56 -11
- package/src/commands/log.ts +134 -69
- package/src/commands/mail.test.ts +162 -0
- package/src/commands/mail.ts +64 -9
- package/src/commands/merge.test.ts +112 -1
- package/src/commands/merge.ts +17 -4
- package/src/commands/monitor.ts +2 -1
- package/src/commands/nudge.test.ts +351 -4
- package/src/commands/nudge.ts +356 -34
- package/src/commands/run.test.ts +43 -7
- package/src/commands/serve/build.test.ts +202 -0
- package/src/commands/serve/build.ts +206 -0
- package/src/commands/serve/coordinator-actions.test.ts +339 -0
- package/src/commands/serve/coordinator-actions.ts +408 -0
- package/src/commands/serve/dev.test.ts +168 -0
- package/src/commands/serve/dev.ts +117 -0
- package/src/commands/serve/mail-actions.test.ts +312 -0
- package/src/commands/serve/mail-actions.ts +167 -0
- package/src/commands/serve/rest.test.ts +1323 -0
- package/src/commands/serve/rest.ts +708 -0
- package/src/commands/serve/static.ts +51 -0
- package/src/commands/serve/ws.test.ts +361 -0
- package/src/commands/serve/ws.ts +332 -0
- package/src/commands/serve.test.ts +459 -0
- package/src/commands/serve.ts +565 -0
- package/src/commands/sling.test.ts +85 -1
- package/src/commands/sling.ts +153 -64
- package/src/commands/status.test.ts +9 -0
- package/src/commands/status.ts +12 -4
- package/src/commands/stop.test.ts +174 -1
- package/src/commands/stop.ts +107 -8
- package/src/commands/supervisor.ts +2 -1
- package/src/commands/watch.test.ts +49 -4
- package/src/commands/watch.ts +153 -28
- package/src/commands/worktree.test.ts +319 -3
- package/src/commands/worktree.ts +86 -0
- package/src/config.test.ts +78 -0
- package/src/config.ts +43 -1
- package/src/doctor/consistency.test.ts +106 -0
- package/src/doctor/consistency.ts +50 -3
- package/src/doctor/serve.test.ts +95 -0
- package/src/doctor/serve.ts +86 -0
- package/src/doctor/types.ts +2 -1
- package/src/doctor/watchdog.ts +57 -1
- package/src/events/tailer.test.ts +234 -1
- package/src/events/tailer.ts +90 -0
- package/src/index.ts +53 -6
- package/src/json.ts +29 -0
- package/src/mail/client.ts +15 -2
- package/src/mail/store.test.ts +82 -0
- package/src/mail/store.ts +41 -4
- package/src/merge/lock.test.ts +149 -0
- package/src/merge/lock.ts +140 -0
- package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
- package/src/runtimes/claude.test.ts +791 -1
- package/src/runtimes/claude.ts +323 -1
- package/src/runtimes/connections.test.ts +141 -1
- package/src/runtimes/connections.ts +73 -4
- package/src/runtimes/headless-connection.test.ts +264 -0
- package/src/runtimes/headless-connection.ts +158 -0
- package/src/runtimes/types.ts +10 -0
- package/src/schema-consistency.test.ts +1 -0
- package/src/sessions/store.test.ts +390 -24
- package/src/sessions/store.ts +184 -19
- package/src/test-setup.test.ts +31 -0
- package/src/test-setup.ts +28 -0
- package/src/types.ts +56 -1
- package/src/utils/pid.test.ts +85 -1
- package/src/utils/pid.ts +86 -1
- package/src/utils/process-scan.test.ts +53 -0
- package/src/utils/process-scan.ts +76 -0
- package/src/watchdog/daemon.test.ts +1520 -411
- package/src/watchdog/daemon.ts +442 -83
- package/src/watchdog/health.test.ts +157 -0
- package/src/watchdog/health.ts +92 -25
- package/src/worktree/process.test.ts +71 -0
- package/src/worktree/process.ts +25 -5
- package/src/worktree/tmux.test.ts +39 -0
- package/src/worktree/tmux.ts +23 -3
- package/templates/CLAUDE.md.tmpl +19 -8
- 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
|
+
});
|