@os-eco/overstory-cli 0.9.4 → 0.11.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.
- package/README.md +50 -19
- package/agents/builder.md +19 -9
- package/agents/coordinator.md +6 -6
- package/agents/lead.md +204 -87
- 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 +219 -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/mail-poll-detect.test.ts +153 -0
- package/src/agents/mail-poll-detect.ts +73 -0
- package/src/agents/overlay.test.ts +60 -4
- package/src/agents/overlay.ts +63 -8
- package/src/agents/scope-detect.test.ts +190 -0
- package/src/agents/scope-detect.ts +146 -0
- 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 +2312 -0
- package/src/agents/turn-runner.ts +1383 -0
- package/src/commands/agents.ts +9 -0
- package/src/commands/clean.ts +54 -0
- package/src/commands/coordinator.test.ts +254 -0
- package/src/commands/coordinator.ts +273 -8
- package/src/commands/dashboard.test.ts +188 -0
- package/src/commands/dashboard.ts +14 -4
- package/src/commands/doctor.ts +3 -1
- 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 +187 -11
- package/src/commands/log.ts +171 -71
- package/src/commands/mail.test.ts +162 -0
- package/src/commands/mail.ts +64 -9
- package/src/commands/merge.test.ts +230 -1
- package/src/commands/merge.ts +68 -12
- 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 +177 -1
- package/src/commands/sling.ts +243 -71
- package/src/commands/status.test.ts +9 -0
- package/src/commands/status.ts +12 -4
- package/src/commands/stop.test.ts +255 -1
- package/src/commands/stop.ts +107 -8
- package/src/commands/watch.test.ts +43 -0
- package/src/commands/watch.ts +153 -28
- package/src/config.ts +23 -0
- package/src/doctor/consistency.test.ts +106 -0
- package/src/doctor/consistency.ts +48 -1
- 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 +57 -6
- package/src/insights/quality-gates.test.ts +141 -0
- package/src/insights/quality-gates.ts +156 -0
- package/src/json.ts +29 -0
- package/src/logging/theme.ts +4 -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/merge/predict.test.ts +387 -0
- package/src/merge/predict.ts +249 -0
- package/src/merge/resolver.ts +1 -1
- package/src/mulch/client.ts +3 -3
- 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 +657 -29
- package/src/sessions/store.ts +286 -23
- package/src/test-setup.test.ts +31 -0
- package/src/test-setup.ts +28 -0
- package/src/types.ts +107 -2
- 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 +1607 -376
- package/src/watchdog/daemon.ts +462 -88
- package/src/watchdog/health.test.ts +282 -0
- package/src/watchdog/health.ts +126 -27
- package/src/worktree/manager.test.ts +218 -1
- package/src/worktree/manager.ts +55 -0
- package/src/worktree/process.test.ts +71 -0
- package/src/worktree/process.ts +25 -5
- package/src/worktree/tmux.test.ts +28 -0
- package/src/worktree/tmux.ts +27 -3
- package/templates/CLAUDE.md.tmpl +19 -8
- package/templates/overlay.md.tmpl +5 -2
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket broadcaster for ov serve.
|
|
3
|
+
*
|
|
4
|
+
* Subscribes to EventStore writes and MailStore inserts, broadcasting
|
|
5
|
+
* to per-run and per-agent rooms. Installs itself via registerWsHandler().
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Database } from "bun:sqlite";
|
|
9
|
+
import type { MailMessage, StoredEvent } from "../../types.ts";
|
|
10
|
+
import { registerWsHandler } from "../serve.ts";
|
|
11
|
+
|
|
12
|
+
type BunWs<T> = import("bun").ServerWebSocket<T>;
|
|
13
|
+
|
|
14
|
+
/** Per-socket data injected on upgrade. */
|
|
15
|
+
interface RoomData {
|
|
16
|
+
rooms: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// === Room registry ===
|
|
20
|
+
|
|
21
|
+
const rooms = new Map<string, Set<BunWs<RoomData>>>();
|
|
22
|
+
|
|
23
|
+
function joinRoom(key: string, ws: BunWs<RoomData>): void {
|
|
24
|
+
let room = rooms.get(key);
|
|
25
|
+
if (room === undefined) {
|
|
26
|
+
room = new Set();
|
|
27
|
+
rooms.set(key, room);
|
|
28
|
+
}
|
|
29
|
+
room.add(ws);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function leaveAllRooms(ws: BunWs<RoomData>): void {
|
|
33
|
+
for (const key of ws.data.rooms) {
|
|
34
|
+
const room = rooms.get(key);
|
|
35
|
+
if (room !== undefined) {
|
|
36
|
+
room.delete(ws);
|
|
37
|
+
if (room.size === 0) {
|
|
38
|
+
rooms.delete(key);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function broadcast(key: string, frame: string): void {
|
|
45
|
+
const room = rooms.get(key);
|
|
46
|
+
if (room === undefined) return;
|
|
47
|
+
for (const ws of room) {
|
|
48
|
+
ws.send(frame);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// === Outbound envelope ===
|
|
53
|
+
|
|
54
|
+
type OutboundFrame =
|
|
55
|
+
| { type: "event"; ts: string; payload: StoredEvent | { batched: true; events: StoredEvent[] } }
|
|
56
|
+
| { type: "mail"; ts: string; payload: { message: MailMessage } }
|
|
57
|
+
| { type: "agent_state"; ts: string; payload: { agentName: string; state: string } };
|
|
58
|
+
|
|
59
|
+
// === Text batching ===
|
|
60
|
+
|
|
61
|
+
const BATCH_WINDOW_MS = 250;
|
|
62
|
+
|
|
63
|
+
interface BatchEntry {
|
|
64
|
+
events: StoredEvent[];
|
|
65
|
+
timer: ReturnType<typeof setTimeout>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const batches = new Map<string, BatchEntry>();
|
|
69
|
+
|
|
70
|
+
function isTextEvent(event: StoredEvent): boolean {
|
|
71
|
+
if ((event.eventType as string) === "text") return true;
|
|
72
|
+
if (event.data !== null) {
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(event.data) as unknown;
|
|
75
|
+
if (
|
|
76
|
+
typeof parsed === "object" &&
|
|
77
|
+
parsed !== null &&
|
|
78
|
+
"delta" in (parsed as Record<string, unknown>)
|
|
79
|
+
) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// ignore parse error
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function fanOutEvent(event: StoredEvent, roomKey: string): void {
|
|
90
|
+
if (isTextEvent(event)) {
|
|
91
|
+
const batchKey = `${roomKey}:${event.agentName}:${event.sessionId ?? ""}`;
|
|
92
|
+
let entry = batches.get(batchKey);
|
|
93
|
+
if (entry === undefined) {
|
|
94
|
+
const timer = setTimeout(() => {
|
|
95
|
+
const e = batches.get(batchKey);
|
|
96
|
+
if (e !== undefined) {
|
|
97
|
+
batches.delete(batchKey);
|
|
98
|
+
const frame: OutboundFrame = {
|
|
99
|
+
type: "event",
|
|
100
|
+
ts: new Date().toISOString(),
|
|
101
|
+
payload: { batched: true, events: e.events },
|
|
102
|
+
};
|
|
103
|
+
broadcast(roomKey, JSON.stringify(frame));
|
|
104
|
+
}
|
|
105
|
+
}, BATCH_WINDOW_MS);
|
|
106
|
+
entry = { events: [], timer };
|
|
107
|
+
batches.set(batchKey, entry);
|
|
108
|
+
}
|
|
109
|
+
entry.events.push(event);
|
|
110
|
+
} else {
|
|
111
|
+
const frame: OutboundFrame = {
|
|
112
|
+
type: "event",
|
|
113
|
+
ts: new Date().toISOString(),
|
|
114
|
+
payload: event,
|
|
115
|
+
};
|
|
116
|
+
broadcast(roomKey, JSON.stringify(frame));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// === DB row shapes ===
|
|
121
|
+
|
|
122
|
+
interface EventRow {
|
|
123
|
+
id: number;
|
|
124
|
+
run_id: string | null;
|
|
125
|
+
agent_name: string;
|
|
126
|
+
session_id: string | null;
|
|
127
|
+
event_type: string;
|
|
128
|
+
tool_name: string | null;
|
|
129
|
+
tool_args: string | null;
|
|
130
|
+
tool_duration_ms: number | null;
|
|
131
|
+
level: string;
|
|
132
|
+
data: string | null;
|
|
133
|
+
created_at: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface MessageRow {
|
|
137
|
+
id: string;
|
|
138
|
+
from_agent: string;
|
|
139
|
+
to_agent: string;
|
|
140
|
+
subject: string;
|
|
141
|
+
body: string;
|
|
142
|
+
type: string;
|
|
143
|
+
priority: string;
|
|
144
|
+
thread_id: string | null;
|
|
145
|
+
payload: string | null;
|
|
146
|
+
read: number;
|
|
147
|
+
created_at: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function rowToStoredEvent(row: EventRow): StoredEvent {
|
|
151
|
+
return {
|
|
152
|
+
id: row.id,
|
|
153
|
+
runId: row.run_id,
|
|
154
|
+
agentName: row.agent_name,
|
|
155
|
+
sessionId: row.session_id,
|
|
156
|
+
eventType: row.event_type as StoredEvent["eventType"],
|
|
157
|
+
toolName: row.tool_name,
|
|
158
|
+
toolArgs: row.tool_args,
|
|
159
|
+
toolDurationMs: row.tool_duration_ms,
|
|
160
|
+
level: row.level as StoredEvent["level"],
|
|
161
|
+
data: row.data,
|
|
162
|
+
createdAt: row.created_at,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function rowToMailMessage(row: MessageRow): MailMessage {
|
|
167
|
+
return {
|
|
168
|
+
id: row.id,
|
|
169
|
+
from: row.from_agent,
|
|
170
|
+
to: row.to_agent,
|
|
171
|
+
subject: row.subject,
|
|
172
|
+
body: row.body,
|
|
173
|
+
type: row.type as MailMessage["type"],
|
|
174
|
+
priority: row.priority as MailMessage["priority"],
|
|
175
|
+
threadId: row.thread_id,
|
|
176
|
+
payload: row.payload,
|
|
177
|
+
read: row.read === 1,
|
|
178
|
+
createdAt: row.created_at,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// === Public API ===
|
|
183
|
+
|
|
184
|
+
export interface BroadcasterOptions {
|
|
185
|
+
eventsDbPath: string;
|
|
186
|
+
mailDbPath: string;
|
|
187
|
+
pollIntervalMs?: number;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Install the WebSocket broadcaster and start pollers.
|
|
192
|
+
* Registers a WsHandler that joins/leaves rooms on open/close.
|
|
193
|
+
* Returns a stop function for cleanup on SIGINT/SIGTERM.
|
|
194
|
+
*/
|
|
195
|
+
export function installBroadcaster(opts: BroadcasterOptions): () => void {
|
|
196
|
+
const interval = opts.pollIntervalMs ?? 250;
|
|
197
|
+
|
|
198
|
+
registerWsHandler({
|
|
199
|
+
getUpgradeData(req: Request): unknown | null {
|
|
200
|
+
const url = new URL(req.url);
|
|
201
|
+
const mailParam = url.searchParams.get("mail");
|
|
202
|
+
if (mailParam === "true") {
|
|
203
|
+
const data: RoomData = { rooms: ["mail"] };
|
|
204
|
+
return data;
|
|
205
|
+
}
|
|
206
|
+
const run = url.searchParams.get("run");
|
|
207
|
+
const agent = url.searchParams.get("agent");
|
|
208
|
+
if (run !== null) {
|
|
209
|
+
const data: RoomData = { rooms: [`run:${run}`] };
|
|
210
|
+
return data;
|
|
211
|
+
}
|
|
212
|
+
if (agent !== null) {
|
|
213
|
+
const data: RoomData = { rooms: [`agent:${agent}`] };
|
|
214
|
+
return data;
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
},
|
|
218
|
+
open(ws: BunWs<unknown>): void {
|
|
219
|
+
const data = ws.data as RoomData;
|
|
220
|
+
for (const key of data.rooms) {
|
|
221
|
+
joinRoom(key, ws as BunWs<RoomData>);
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
close(ws: BunWs<unknown>): void {
|
|
225
|
+
leaveAllRooms(ws as BunWs<RoomData>);
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const eventsDb = new Database(opts.eventsDbPath);
|
|
230
|
+
eventsDb.exec("PRAGMA journal_mode=WAL");
|
|
231
|
+
eventsDb.exec("PRAGMA busy_timeout=5000");
|
|
232
|
+
|
|
233
|
+
const mailDb = new Database(opts.mailDbPath);
|
|
234
|
+
mailDb.exec("PRAGMA journal_mode=WAL");
|
|
235
|
+
mailDb.exec("PRAGMA busy_timeout=5000");
|
|
236
|
+
|
|
237
|
+
// Seed event cursor at the current MAX(id) so we only tail new rows
|
|
238
|
+
let lastEventId = 0;
|
|
239
|
+
try {
|
|
240
|
+
const r = eventsDb
|
|
241
|
+
.prepare<{ max_id: number | null }, []>("SELECT MAX(id) AS max_id FROM events")
|
|
242
|
+
.get();
|
|
243
|
+
lastEventId = r?.max_id ?? 0;
|
|
244
|
+
} catch {
|
|
245
|
+
// events table not yet created
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Seed mail cursor at current MAX(created_at)
|
|
249
|
+
let lastMailTs = "";
|
|
250
|
+
try {
|
|
251
|
+
const r = mailDb
|
|
252
|
+
.prepare<{ max_ts: string | null }, []>("SELECT MAX(created_at) AS max_ts FROM messages")
|
|
253
|
+
.get();
|
|
254
|
+
lastMailTs = r?.max_ts ?? "";
|
|
255
|
+
} catch {
|
|
256
|
+
// messages table not yet created
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Event poller
|
|
260
|
+
const eventTimer = setInterval(() => {
|
|
261
|
+
try {
|
|
262
|
+
const rows = eventsDb
|
|
263
|
+
.prepare<EventRow, { $lastId: number }>(
|
|
264
|
+
"SELECT * FROM events WHERE id > $lastId ORDER BY id ASC",
|
|
265
|
+
)
|
|
266
|
+
.all({ $lastId: lastEventId });
|
|
267
|
+
for (const row of rows) {
|
|
268
|
+
if (row.id > lastEventId) lastEventId = row.id;
|
|
269
|
+
const event = rowToStoredEvent(row);
|
|
270
|
+
fanOutEvent(event, `agent:${event.agentName}`);
|
|
271
|
+
if (event.runId !== null) {
|
|
272
|
+
fanOutEvent(event, `run:${event.runId}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
} catch {
|
|
276
|
+
// DB not ready yet; retry next tick
|
|
277
|
+
}
|
|
278
|
+
}, interval);
|
|
279
|
+
|
|
280
|
+
// Mail poller
|
|
281
|
+
const mailTimer = setInterval(() => {
|
|
282
|
+
try {
|
|
283
|
+
const rows = mailDb
|
|
284
|
+
.prepare<MessageRow, { $lastTs: string }>(
|
|
285
|
+
"SELECT * FROM messages WHERE created_at > $lastTs ORDER BY created_at ASC",
|
|
286
|
+
)
|
|
287
|
+
.all({ $lastTs: lastMailTs });
|
|
288
|
+
for (const row of rows) {
|
|
289
|
+
if (row.created_at > lastMailTs) lastMailTs = row.created_at;
|
|
290
|
+
const message = rowToMailMessage(row);
|
|
291
|
+
const frame: OutboundFrame = {
|
|
292
|
+
type: "mail",
|
|
293
|
+
ts: new Date().toISOString(),
|
|
294
|
+
payload: { message },
|
|
295
|
+
};
|
|
296
|
+
const frameStr = JSON.stringify(frame);
|
|
297
|
+
broadcast(`agent:${message.to}`, frameStr);
|
|
298
|
+
broadcast(`agent:${message.from}`, frameStr);
|
|
299
|
+
broadcast("mail", frameStr);
|
|
300
|
+
}
|
|
301
|
+
} catch {
|
|
302
|
+
// DB not ready yet; retry next tick
|
|
303
|
+
}
|
|
304
|
+
}, interval);
|
|
305
|
+
|
|
306
|
+
return function stopBroadcaster(): void {
|
|
307
|
+
clearInterval(eventTimer);
|
|
308
|
+
clearInterval(mailTimer);
|
|
309
|
+
for (const [, entry] of batches) {
|
|
310
|
+
clearTimeout(entry.timer);
|
|
311
|
+
}
|
|
312
|
+
batches.clear();
|
|
313
|
+
eventsDb.close();
|
|
314
|
+
mailDb.close();
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// === Test helpers ===
|
|
319
|
+
|
|
320
|
+
/** Returns the current number of active rooms (for disconnect verification). */
|
|
321
|
+
export function _getRoomCount(): number {
|
|
322
|
+
return rooms.size;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Clears room and batch state between tests. */
|
|
326
|
+
export function _resetRooms(): void {
|
|
327
|
+
rooms.clear();
|
|
328
|
+
for (const [, entry] of batches) {
|
|
329
|
+
clearTimeout(entry.timer);
|
|
330
|
+
}
|
|
331
|
+
batches.clear();
|
|
332
|
+
}
|