@johpaz/hive-orchestrator 1.0.8
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 +78 -0
- package/package.json +17 -0
- package/src/index.ts +95 -0
- package/src/process-manager.test.ts +172 -0
- package/src/process-manager.ts +212 -0
- package/src/schemas.test.ts +199 -0
- package/src/schemas.ts +133 -0
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# @johpaz/orchestrator
|
|
2
|
+
|
|
3
|
+
Subagent CLI Orchestrator for the Hive ecosystem. Exposes a **local WebSocket mesh** (via `Bun.serve`) that spawns and supervises CLI AI tools (e.g. `opencode`, `gemini`, `qwen`) as child processes, streaming their output and telemetry to any connected dashboard client.
|
|
4
|
+
|
|
5
|
+
## Architecture
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Dashboard (Angular) ──WS──► @johpaz/orchestrator ──Bun.spawn──► CLI tool (opencode / gemini / qwen…)
|
|
9
|
+
◄──WS──── telemetry events (stdout chunks, progress, tokens)
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Files
|
|
13
|
+
|
|
14
|
+
| File | Purpose |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `src/index.ts` | Bun.serve entry point — HTTP health + WebSocket handler |
|
|
17
|
+
| `src/schemas.ts` | Zod schemas: `AgentRole`, `SubagentConfig`, `TelemetryEvent`, `DashboardCommand` |
|
|
18
|
+
| `src/process-manager.ts` | Spawns/kills processes, streams stdout/stderr, parses `HIVE_PROGRESS` and `HIVE_TOKENS` hints |
|
|
19
|
+
|
|
20
|
+
## Running
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# From the monorepo root:
|
|
24
|
+
bun run packages/orchestrator/src/index.ts
|
|
25
|
+
|
|
26
|
+
# Or from this package:
|
|
27
|
+
bun run src/index.ts
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Server starts on `ws://localhost:18791` by default (`ORCHESTRATOR_PORT` env override).
|
|
31
|
+
|
|
32
|
+
## WebSocket Protocol
|
|
33
|
+
|
|
34
|
+
### Dashboard → Orchestrator
|
|
35
|
+
|
|
36
|
+
```jsonc
|
|
37
|
+
// Launch a CLI subagent
|
|
38
|
+
{ "cmd": "launch", "taskId": "uuid", "prompt": "Refactor auth module", "config": { "role": "development", "cli": "opencode" } }
|
|
39
|
+
|
|
40
|
+
// Cancel a running agent
|
|
41
|
+
{ "cmd": "cancel", "taskId": "uuid" }
|
|
42
|
+
|
|
43
|
+
// Request current status snapshot
|
|
44
|
+
{ "cmd": "status" }
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Orchestrator → Dashboard (broadcast)
|
|
48
|
+
|
|
49
|
+
| Event type | Description |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `orchestrator:status` | Full snapshot of all agents (sent on connect and on `status` command) |
|
|
52
|
+
| `agent:started` | Process PID and CLI tool |
|
|
53
|
+
| `agent:output` | stdout/stderr chunk |
|
|
54
|
+
| `agent:progress` | 0–100 percent (from `HIVE_PROGRESS:<n>` in stdout) |
|
|
55
|
+
| `agent:token_usage` | Token counters + model (from `HIVE_TOKENS:input=<n>,output=<n>,model=<name>`) |
|
|
56
|
+
| `agent:finished` | Exit code |
|
|
57
|
+
| `agent:cancelled` | Cancelled by dashboard |
|
|
58
|
+
| `agent:error` | Non-zero exit or spawn error |
|
|
59
|
+
|
|
60
|
+
## HIVE Protocol Hints
|
|
61
|
+
|
|
62
|
+
CLI tools can emit structured hints in their stdout to feed the dashboard:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
HIVE_PROGRESS:42
|
|
66
|
+
HIVE_TOKENS:input=1200,output=350,model=gemini-2.0-flash
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Tests
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
bun test src/schemas.test.ts
|
|
73
|
+
bun test src/process-manager.test.ts
|
|
74
|
+
# or all at once:
|
|
75
|
+
bun test
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Tests cover: schema validation, launch/cancel lifecycle, stdout streaming, progress/token hint parsing.
|
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@johpaz/hive-orchestrator",
|
|
3
|
+
"version": "1.0.8",
|
|
4
|
+
"description": "Subagent CLI Orchestrator and WebSocket Mesh local server",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"dev": "bun run src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@johpaz/hive-mcp": "workspace:*",
|
|
12
|
+
"zod": "^4.3.6"
|
|
13
|
+
},
|
|
14
|
+
"peerDependencies": {
|
|
15
|
+
"@johpaz/hive-core": "workspace:*"
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { ProcessManager } from "./process-manager.ts";
|
|
2
|
+
import { DashboardCommand } from "./schemas.ts";
|
|
3
|
+
|
|
4
|
+
const ORCHESTRATOR_PORT = parseInt(process.env.ORCHESTRATOR_PORT ?? "18791", 10);
|
|
5
|
+
|
|
6
|
+
const manager = new ProcessManager();
|
|
7
|
+
|
|
8
|
+
const server = Bun.serve<{ id: string }>({
|
|
9
|
+
port: ORCHESTRATOR_PORT,
|
|
10
|
+
|
|
11
|
+
// ── HTTP routes ──────────────────────────────────────────────────────────
|
|
12
|
+
fetch(req, server) {
|
|
13
|
+
const url = new URL(req.url);
|
|
14
|
+
|
|
15
|
+
// Upgrade WebSocket connections
|
|
16
|
+
if (url.pathname === "/ws") {
|
|
17
|
+
const id = crypto.randomUUID();
|
|
18
|
+
const upgraded = server.upgrade(req, { data: { id } });
|
|
19
|
+
if (upgraded) return undefined;
|
|
20
|
+
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Simple REST ping
|
|
24
|
+
if (url.pathname === "/health") {
|
|
25
|
+
return new Response(JSON.stringify({ ok: true, port: ORCHESTRATOR_PORT }), {
|
|
26
|
+
headers: { "Content-Type": "application/json" },
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Status snapshot (REST fallback for the dashboard initial load)
|
|
31
|
+
if (url.pathname === "/status") {
|
|
32
|
+
return new Response(JSON.stringify(manager.status()), {
|
|
33
|
+
headers: { "Content-Type": "application/json" },
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return new Response("Not found", { status: 404 });
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
// ── WebSocket handlers ───────────────────────────────────────────────────
|
|
41
|
+
websocket: {
|
|
42
|
+
open(ws) {
|
|
43
|
+
manager.subscribe(ws);
|
|
44
|
+
// Immediately send the current snapshot to the newly connected client
|
|
45
|
+
ws.send(JSON.stringify(manager.status()));
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
async message(ws, raw) {
|
|
49
|
+
let parsed: unknown;
|
|
50
|
+
try {
|
|
51
|
+
parsed = JSON.parse(typeof raw === "string" ? raw : new TextDecoder().decode(raw));
|
|
52
|
+
} catch {
|
|
53
|
+
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const cmd = DashboardCommand.safeParse(parsed);
|
|
58
|
+
if (!cmd.success) {
|
|
59
|
+
ws.send(JSON.stringify({ type: "error", message: cmd.error.message }));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const command = cmd.data;
|
|
64
|
+
|
|
65
|
+
switch (command.cmd) {
|
|
66
|
+
case "launch": {
|
|
67
|
+
try {
|
|
68
|
+
const pid = await manager.launch(command.taskId, command.config, command.prompt);
|
|
69
|
+
ws.send(JSON.stringify({ type: "ack", cmd: "launch", taskId: command.taskId, pid }));
|
|
70
|
+
} catch (err: any) {
|
|
71
|
+
ws.send(JSON.stringify({ type: "error", message: err.message }));
|
|
72
|
+
}
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
case "cancel": {
|
|
77
|
+
const ok = manager.cancel(command.taskId);
|
|
78
|
+
ws.send(JSON.stringify({ type: "ack", cmd: "cancel", taskId: command.taskId, ok }));
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
case "status": {
|
|
83
|
+
ws.send(JSON.stringify(manager.status()));
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
close(ws) {
|
|
90
|
+
manager.unsubscribe(ws);
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
console.log(`🐝 Hive Orchestrator running on ws://localhost:${server.port}`);
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { ProcessManager } from "./process-manager.ts";
|
|
3
|
+
|
|
4
|
+
describe("ProcessManager", () => {
|
|
5
|
+
let manager: ProcessManager;
|
|
6
|
+
const capturedEvents: any[] = [];
|
|
7
|
+
|
|
8
|
+
// We mock a WebSocket-like subscriber
|
|
9
|
+
const mockWs = {
|
|
10
|
+
id: "test-ws",
|
|
11
|
+
send(payload: string) {
|
|
12
|
+
capturedEvents.push(JSON.parse(payload));
|
|
13
|
+
},
|
|
14
|
+
} as any;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
manager = new ProcessManager();
|
|
18
|
+
capturedEvents.length = 0;
|
|
19
|
+
manager.subscribe(mockWs);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
manager.unsubscribe(mockWs);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("initial status has no agents", () => {
|
|
27
|
+
const status = manager.status();
|
|
28
|
+
expect(status.type).toBe("orchestrator:status");
|
|
29
|
+
expect(status.agents).toHaveLength(0);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("launches a simple process and receives started event", async () => {
|
|
33
|
+
const taskId = "test-task-1";
|
|
34
|
+
const pid = await manager.launch(
|
|
35
|
+
taskId,
|
|
36
|
+
{ role: "development", cli: "echo", args: ["hello"], timeoutSeconds: 10 },
|
|
37
|
+
"" // empty prompt sent to stdin
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
expect(typeof pid).toBe("number");
|
|
41
|
+
expect(pid).toBeGreaterThan(0);
|
|
42
|
+
|
|
43
|
+
// agent:started should be the first broadcast
|
|
44
|
+
expect(capturedEvents[0].type).toBe("agent:started");
|
|
45
|
+
expect(capturedEvents[0].taskId).toBe(taskId);
|
|
46
|
+
expect(capturedEvents[0].cli).toBe("echo");
|
|
47
|
+
|
|
48
|
+
// Wait for echo to finish
|
|
49
|
+
await Bun.sleep(200);
|
|
50
|
+
|
|
51
|
+
// Verify status reflects the finished agent
|
|
52
|
+
const status = manager.status();
|
|
53
|
+
const agent = status.agents.find((a) => a.taskId === taskId);
|
|
54
|
+
expect(agent).toBeDefined();
|
|
55
|
+
expect(agent!.state).toBe("finished");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("throws if same taskId is launched twice", async () => {
|
|
59
|
+
const taskId = "test-task-dup";
|
|
60
|
+
await manager.launch(
|
|
61
|
+
taskId,
|
|
62
|
+
{ role: "testing", cli: "sleep", args: ["1"], timeoutSeconds: 10 },
|
|
63
|
+
""
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(
|
|
67
|
+
manager.launch(taskId, { role: "testing", cli: "sleep", args: ["1"], timeoutSeconds: 10 }, "")
|
|
68
|
+
).rejects.toThrow(`Task ${taskId} is already running`);
|
|
69
|
+
|
|
70
|
+
manager.cancel(taskId);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("can cancel a running process", async () => {
|
|
74
|
+
const taskId = "test-task-cancel";
|
|
75
|
+
await manager.launch(
|
|
76
|
+
taskId,
|
|
77
|
+
{ role: "architecture", cli: "sleep", args: ["30"], timeoutSeconds: 60 },
|
|
78
|
+
""
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const cancelled = manager.cancel(taskId);
|
|
82
|
+
expect(cancelled).toBe(true);
|
|
83
|
+
|
|
84
|
+
await Bun.sleep(100);
|
|
85
|
+
|
|
86
|
+
const cancelEvent = capturedEvents.find((e) => e.type === "agent:cancelled");
|
|
87
|
+
expect(cancelEvent).toBeDefined();
|
|
88
|
+
expect(cancelEvent.taskId).toBe(taskId);
|
|
89
|
+
|
|
90
|
+
const status = manager.status();
|
|
91
|
+
const agent = status.agents.find((a) => a.taskId === taskId);
|
|
92
|
+
expect(agent!.state).toBe("cancelled");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("returns false when cancelling a non-existent task", () => {
|
|
96
|
+
const result = manager.cancel("non-existent-task");
|
|
97
|
+
expect(result).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("broadcasts output chunks from stdout", async () => {
|
|
101
|
+
const taskId = "test-task-output";
|
|
102
|
+
await manager.launch(
|
|
103
|
+
taskId,
|
|
104
|
+
{ role: "documentation", cli: "echo", args: ["HIVE_TEST_OUTPUT"], timeoutSeconds: 10 },
|
|
105
|
+
""
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
await Bun.sleep(300);
|
|
109
|
+
|
|
110
|
+
const outputEvents = capturedEvents.filter((e) => e.type === "agent:output" && e.taskId === taskId);
|
|
111
|
+
expect(outputEvents.length).toBeGreaterThan(0);
|
|
112
|
+
const combined = outputEvents.map((e) => e.chunk).join("");
|
|
113
|
+
expect(combined).toContain("HIVE_TEST_OUTPUT");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("parses HIVE_PROGRESS hints from stdout", async () => {
|
|
117
|
+
const taskId = "test-task-progress";
|
|
118
|
+
// We use printf to emit a progress hint
|
|
119
|
+
await manager.launch(
|
|
120
|
+
taskId,
|
|
121
|
+
{ role: "testing", cli: "sh", args: ["-c", "printf 'HIVE_PROGRESS:75\\n'"], timeoutSeconds: 10 },
|
|
122
|
+
""
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
await Bun.sleep(300);
|
|
126
|
+
|
|
127
|
+
const progressEvents = capturedEvents.filter(
|
|
128
|
+
(e) => e.type === "agent:progress" && e.taskId === taskId
|
|
129
|
+
);
|
|
130
|
+
expect(progressEvents.length).toBeGreaterThan(0);
|
|
131
|
+
expect(progressEvents[0].percent).toBe(75);
|
|
132
|
+
|
|
133
|
+
const status = manager.status();
|
|
134
|
+
const agent = status.agents.find((a) => a.taskId === taskId);
|
|
135
|
+
expect(agent!.progress).toBe(75);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("parses HIVE_TOKENS hints from stdout", async () => {
|
|
139
|
+
const taskId = "test-task-tokens";
|
|
140
|
+
await manager.launch(
|
|
141
|
+
taskId,
|
|
142
|
+
{
|
|
143
|
+
role: "development",
|
|
144
|
+
cli: "sh",
|
|
145
|
+
args: ["-c", "printf 'HIVE_TOKENS:input=150,output=300,model=gemini-2.0-flash\\n'"],
|
|
146
|
+
timeoutSeconds: 10,
|
|
147
|
+
},
|
|
148
|
+
""
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
await Bun.sleep(300);
|
|
152
|
+
|
|
153
|
+
const tokenEvents = capturedEvents.filter(
|
|
154
|
+
(e) => e.type === "agent:token_usage" && e.taskId === taskId
|
|
155
|
+
);
|
|
156
|
+
expect(tokenEvents.length).toBeGreaterThan(0);
|
|
157
|
+
expect(tokenEvents[0].inputTokens).toBe(150);
|
|
158
|
+
expect(tokenEvents[0].outputTokens).toBe(300);
|
|
159
|
+
expect(tokenEvents[0].model).toBe("gemini-2.0-flash");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("status reflects all running agents", async () => {
|
|
163
|
+
await manager.launch("t-a", { role: "development", cli: "sleep", args: ["2"], timeoutSeconds: 10 }, "");
|
|
164
|
+
await manager.launch("t-b", { role: "testing", cli: "sleep", args: ["2"], timeoutSeconds: 10 }, "");
|
|
165
|
+
|
|
166
|
+
const status = manager.status();
|
|
167
|
+
expect(status.agents).toHaveLength(2);
|
|
168
|
+
|
|
169
|
+
manager.cancel("t-a");
|
|
170
|
+
manager.cancel("t-b");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import type { ServerWebSocket } from "bun";
|
|
2
|
+
import type { SubagentConfig, TelemetryEvent } from "./schemas.ts";
|
|
3
|
+
import { AgentRole } from "./schemas.ts";
|
|
4
|
+
|
|
5
|
+
/** Live record of a running subagent process */
|
|
6
|
+
interface AgentRecord {
|
|
7
|
+
taskId: string;
|
|
8
|
+
config: SubagentConfig;
|
|
9
|
+
pid: number;
|
|
10
|
+
proc: ReturnType<typeof Bun.spawn>;
|
|
11
|
+
state: "running" | "finished" | "cancelled" | "error";
|
|
12
|
+
progress: number;
|
|
13
|
+
tokens: { input: number; output: number };
|
|
14
|
+
model?: string;
|
|
15
|
+
startedAt: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type WsData = { id: string };
|
|
19
|
+
|
|
20
|
+
export class ProcessManager {
|
|
21
|
+
private agents = new Map<string, AgentRecord>();
|
|
22
|
+
private sockets = new Set<ServerWebSocket<WsData>>();
|
|
23
|
+
|
|
24
|
+
// ── Socket subscription ──────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
subscribe(ws: ServerWebSocket<WsData>) {
|
|
27
|
+
this.sockets.add(ws);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
unsubscribe(ws: ServerWebSocket<WsData>) {
|
|
31
|
+
this.sockets.delete(ws);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private broadcast(event: TelemetryEvent) {
|
|
35
|
+
const payload = JSON.stringify(event);
|
|
36
|
+
for (const ws of this.sockets) {
|
|
37
|
+
try {
|
|
38
|
+
ws.send(payload);
|
|
39
|
+
} catch {
|
|
40
|
+
this.sockets.delete(ws);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Launch ───────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
async launch(taskId: string, config: SubagentConfig, prompt: string) {
|
|
48
|
+
if (this.agents.has(taskId)) {
|
|
49
|
+
throw new Error(`Task ${taskId} is already running`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const args = [config.cli, ...config.args];
|
|
53
|
+
const proc = Bun.spawn(args, {
|
|
54
|
+
cwd: config.cwd ?? process.cwd(),
|
|
55
|
+
stdin: "pipe",
|
|
56
|
+
stdout: "pipe",
|
|
57
|
+
stderr: "pipe",
|
|
58
|
+
env: { ...process.env, HIVE_ROLE: config.role },
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const record: AgentRecord = {
|
|
62
|
+
taskId,
|
|
63
|
+
config,
|
|
64
|
+
pid: proc.pid,
|
|
65
|
+
proc,
|
|
66
|
+
state: "running",
|
|
67
|
+
progress: 0,
|
|
68
|
+
tokens: { input: 0, output: 0 },
|
|
69
|
+
startedAt: Date.now(),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
this.agents.set(taskId, record);
|
|
73
|
+
|
|
74
|
+
this.broadcast({
|
|
75
|
+
type: "agent:started",
|
|
76
|
+
ts: Date.now(),
|
|
77
|
+
role: config.role,
|
|
78
|
+
pid: proc.pid,
|
|
79
|
+
cli: config.cli,
|
|
80
|
+
taskId,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Write the prompt to stdin then close it
|
|
84
|
+
proc.stdin.write(prompt);
|
|
85
|
+
proc.stdin.end();
|
|
86
|
+
|
|
87
|
+
// Stream stdout
|
|
88
|
+
this.pipeStream(record, proc.stdout, "stdout");
|
|
89
|
+
// Stream stderr
|
|
90
|
+
this.pipeStream(record, proc.stderr, "stderr");
|
|
91
|
+
|
|
92
|
+
// Wait for process exit
|
|
93
|
+
proc.exited.then((exitCode) => {
|
|
94
|
+
const r = this.agents.get(taskId);
|
|
95
|
+
if (!r || r.state === "cancelled") return;
|
|
96
|
+
r.state = exitCode === 0 ? "finished" : "error";
|
|
97
|
+
if (exitCode === 0) {
|
|
98
|
+
this.broadcast({
|
|
99
|
+
type: "agent:finished",
|
|
100
|
+
ts: Date.now(),
|
|
101
|
+
role: config.role,
|
|
102
|
+
taskId,
|
|
103
|
+
exitCode,
|
|
104
|
+
});
|
|
105
|
+
} else {
|
|
106
|
+
this.broadcast({
|
|
107
|
+
type: "agent:error",
|
|
108
|
+
ts: Date.now(),
|
|
109
|
+
role: config.role,
|
|
110
|
+
taskId,
|
|
111
|
+
message: `Process exited with code ${exitCode}`,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return record.pid;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Cancel ───────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
cancel(taskId: string) {
|
|
122
|
+
const record = this.agents.get(taskId);
|
|
123
|
+
if (!record) return false;
|
|
124
|
+
record.state = "cancelled";
|
|
125
|
+
record.proc.kill();
|
|
126
|
+
this.broadcast({
|
|
127
|
+
type: "agent:cancelled",
|
|
128
|
+
ts: Date.now(),
|
|
129
|
+
role: record.config.role,
|
|
130
|
+
taskId,
|
|
131
|
+
});
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Status snapshot ──────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
status(): TelemetryEvent {
|
|
138
|
+
return {
|
|
139
|
+
type: "orchestrator:status",
|
|
140
|
+
ts: Date.now(),
|
|
141
|
+
agents: [...this.agents.values()].map((r) => ({
|
|
142
|
+
taskId: r.taskId,
|
|
143
|
+
role: r.config.role,
|
|
144
|
+
cli: r.config.cli,
|
|
145
|
+
pid: r.pid,
|
|
146
|
+
state: r.state,
|
|
147
|
+
progress: r.progress,
|
|
148
|
+
tokens: r.tokens,
|
|
149
|
+
model: r.model,
|
|
150
|
+
})),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
private async pipeStream(
|
|
157
|
+
record: AgentRecord,
|
|
158
|
+
stream: ReadableStream<Uint8Array>,
|
|
159
|
+
kind: "stdout" | "stderr"
|
|
160
|
+
) {
|
|
161
|
+
const reader = stream.getReader();
|
|
162
|
+
const decoder = new TextDecoder();
|
|
163
|
+
try {
|
|
164
|
+
while (true) {
|
|
165
|
+
const { done, value } = await reader.read();
|
|
166
|
+
if (done) break;
|
|
167
|
+
const chunk = decoder.decode(value);
|
|
168
|
+
|
|
169
|
+
// Parse progress hints: lines containing "HIVE_PROGRESS:<n>"
|
|
170
|
+
for (const line of chunk.split("\n")) {
|
|
171
|
+
const m = line.match(/HIVE_PROGRESS:(\d+)/);
|
|
172
|
+
if (m) {
|
|
173
|
+
record.progress = Math.min(100, parseInt(m[1], 10));
|
|
174
|
+
this.broadcast({
|
|
175
|
+
type: "agent:progress",
|
|
176
|
+
ts: Date.now(),
|
|
177
|
+
role: record.config.role,
|
|
178
|
+
taskId: record.taskId,
|
|
179
|
+
percent: record.progress,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
// Parse token hints: "HIVE_TOKENS:input=<n>,output=<n>,model=<name>"
|
|
183
|
+
const t = line.match(/HIVE_TOKENS:input=(\d+),output=(\d+)(?:,model=(.+))?/);
|
|
184
|
+
if (t) {
|
|
185
|
+
record.tokens = { input: parseInt(t[1], 10), output: parseInt(t[2], 10) };
|
|
186
|
+
if (t[3]) record.model = t[3].trim();
|
|
187
|
+
this.broadcast({
|
|
188
|
+
type: "agent:token_usage",
|
|
189
|
+
ts: Date.now(),
|
|
190
|
+
role: record.config.role,
|
|
191
|
+
taskId: record.taskId,
|
|
192
|
+
inputTokens: record.tokens.input,
|
|
193
|
+
outputTokens: record.tokens.output,
|
|
194
|
+
model: record.model ?? "unknown",
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
this.broadcast({
|
|
200
|
+
type: "agent:output",
|
|
201
|
+
ts: Date.now(),
|
|
202
|
+
role: record.config.role,
|
|
203
|
+
taskId: record.taskId,
|
|
204
|
+
stream: kind,
|
|
205
|
+
chunk,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
// stream closed
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import {
|
|
4
|
+
AgentRole,
|
|
5
|
+
SubagentConfig,
|
|
6
|
+
DashboardCommand,
|
|
7
|
+
TelemetryEvent,
|
|
8
|
+
AgentStartedEvent,
|
|
9
|
+
AgentOutputEvent,
|
|
10
|
+
AgentProgressEvent,
|
|
11
|
+
AgentTokenUsageEvent,
|
|
12
|
+
AgentFinishedEvent,
|
|
13
|
+
AgentErrorEvent,
|
|
14
|
+
AgentCancelledEvent,
|
|
15
|
+
OrchestratorStatusEvent,
|
|
16
|
+
} from "./schemas.ts";
|
|
17
|
+
|
|
18
|
+
// ─── AgentRole ───────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
describe("AgentRole", () => {
|
|
21
|
+
it("accepts all valid roles", () => {
|
|
22
|
+
const roles = ["architecture", "development", "testing", "documentation"] as const;
|
|
23
|
+
for (const role of roles) {
|
|
24
|
+
expect(AgentRole.parse(role)).toBe(role);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("rejects unknown roles", () => {
|
|
29
|
+
expect(() => AgentRole.parse("design")).toThrow();
|
|
30
|
+
expect(() => AgentRole.parse("")).toThrow();
|
|
31
|
+
expect(() => AgentRole.parse(123)).toThrow();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// ─── SubagentConfig ──────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
describe("SubagentConfig", () => {
|
|
38
|
+
it("parses a minimal config with defaults", () => {
|
|
39
|
+
const result = SubagentConfig.parse({ role: "development", cli: "opencode" });
|
|
40
|
+
expect(result.role).toBe("development");
|
|
41
|
+
expect(result.cli).toBe("opencode");
|
|
42
|
+
expect(result.args).toEqual([]);
|
|
43
|
+
expect(result.timeoutSeconds).toBe(600);
|
|
44
|
+
expect(result.cwd).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("parses a full config", () => {
|
|
48
|
+
const result = SubagentConfig.parse({
|
|
49
|
+
role: "architecture",
|
|
50
|
+
cli: "gemini",
|
|
51
|
+
args: ["--model", "gemini-2.0-flash"],
|
|
52
|
+
cwd: "/tmp/project",
|
|
53
|
+
timeoutSeconds: 300,
|
|
54
|
+
});
|
|
55
|
+
expect(result.args).toEqual(["--model", "gemini-2.0-flash"]);
|
|
56
|
+
expect(result.cwd).toBe("/tmp/project");
|
|
57
|
+
expect(result.timeoutSeconds).toBe(300);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("rejects missing required fields", () => {
|
|
61
|
+
expect(() => SubagentConfig.parse({ cli: "opencode" })).toThrow(); // missing role
|
|
62
|
+
expect(() => SubagentConfig.parse({ role: "development" })).toThrow(); // missing cli
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ─── TelemetryEvent discriminated union ─────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
describe("TelemetryEvent", () => {
|
|
69
|
+
const base = { ts: Date.now(), role: "development" as const, taskId: "t-123" };
|
|
70
|
+
|
|
71
|
+
it("parses agent:started", () => {
|
|
72
|
+
const ev = TelemetryEvent.parse({ ...base, type: "agent:started", pid: 9999, cli: "opencode" });
|
|
73
|
+
expect(ev.type).toBe("agent:started");
|
|
74
|
+
if (ev.type === "agent:started") expect(ev.pid).toBe(9999);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("parses agent:output for stdout", () => {
|
|
78
|
+
const ev = TelemetryEvent.parse({ ...base, type: "agent:output", stream: "stdout", chunk: "Hello" });
|
|
79
|
+
expect(ev.type).toBe("agent:output");
|
|
80
|
+
if (ev.type === "agent:output") {
|
|
81
|
+
expect(ev.chunk).toBe("Hello");
|
|
82
|
+
expect(ev.stream).toBe("stdout");
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("parses agent:output for stderr", () => {
|
|
87
|
+
const ev = TelemetryEvent.parse({ ...base, type: "agent:output", stream: "stderr", chunk: "error msg" });
|
|
88
|
+
if (ev.type === "agent:output") expect(ev.stream).toBe("stderr");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("parses agent:progress", () => {
|
|
92
|
+
const ev = TelemetryEvent.parse({ ...base, type: "agent:progress", percent: 42 });
|
|
93
|
+
if (ev.type === "agent:progress") expect(ev.percent).toBe(42);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("rejects progress > 100", () => {
|
|
97
|
+
expect(() => TelemetryEvent.parse({ ...base, type: "agent:progress", percent: 101 })).toThrow();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("rejects progress < 0", () => {
|
|
101
|
+
expect(() => TelemetryEvent.parse({ ...base, type: "agent:progress", percent: -1 })).toThrow();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("parses agent:token_usage", () => {
|
|
105
|
+
const ev = TelemetryEvent.parse({
|
|
106
|
+
...base,
|
|
107
|
+
type: "agent:token_usage",
|
|
108
|
+
inputTokens: 100,
|
|
109
|
+
outputTokens: 200,
|
|
110
|
+
model: "gemini-2.0-flash",
|
|
111
|
+
});
|
|
112
|
+
if (ev.type === "agent:token_usage") {
|
|
113
|
+
expect(ev.inputTokens).toBe(100);
|
|
114
|
+
expect(ev.model).toBe("gemini-2.0-flash");
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("parses agent:finished", () => {
|
|
119
|
+
const ev = TelemetryEvent.parse({ ...base, type: "agent:finished", exitCode: 0 });
|
|
120
|
+
if (ev.type === "agent:finished") expect(ev.exitCode).toBe(0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("parses agent:error", () => {
|
|
124
|
+
const ev = TelemetryEvent.parse({ ...base, type: "agent:error", message: "crash" });
|
|
125
|
+
if (ev.type === "agent:error") expect(ev.message).toBe("crash");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("parses agent:cancelled", () => {
|
|
129
|
+
const ev = TelemetryEvent.parse({ ...base, type: "agent:cancelled" });
|
|
130
|
+
expect(ev.type).toBe("agent:cancelled");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("parses orchestrator:status", () => {
|
|
134
|
+
const ev = TelemetryEvent.parse({
|
|
135
|
+
type: "orchestrator:status",
|
|
136
|
+
ts: Date.now(),
|
|
137
|
+
agents: [
|
|
138
|
+
{
|
|
139
|
+
taskId: "t-1",
|
|
140
|
+
role: "development",
|
|
141
|
+
cli: "opencode",
|
|
142
|
+
pid: 1234,
|
|
143
|
+
state: "running",
|
|
144
|
+
progress: 50,
|
|
145
|
+
tokens: { input: 10, output: 20 },
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
});
|
|
149
|
+
expect(ev.type).toBe("orchestrator:status");
|
|
150
|
+
if (ev.type === "orchestrator:status") {
|
|
151
|
+
expect(ev.agents).toHaveLength(1);
|
|
152
|
+
expect(ev.agents[0].state).toBe("running");
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("rejects unknown event types", () => {
|
|
157
|
+
expect(() => TelemetryEvent.parse({ ...base, type: "agent:unknown" })).toThrow();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ─── DashboardCommand ────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
describe("DashboardCommand", () => {
|
|
164
|
+
it("parses launch command", () => {
|
|
165
|
+
const cmd = DashboardCommand.parse({
|
|
166
|
+
cmd: "launch",
|
|
167
|
+
taskId: "t-456",
|
|
168
|
+
prompt: "Refactor auth module",
|
|
169
|
+
config: { role: "development", cli: "opencode" },
|
|
170
|
+
});
|
|
171
|
+
expect(cmd.cmd).toBe("launch");
|
|
172
|
+
if (cmd.cmd === "launch") expect(cmd.prompt).toBe("Refactor auth module");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("parses cancel command", () => {
|
|
176
|
+
const cmd = DashboardCommand.parse({ cmd: "cancel", taskId: "t-456" });
|
|
177
|
+
expect(cmd.cmd).toBe("cancel");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("parses status command", () => {
|
|
181
|
+
const cmd = DashboardCommand.parse({ cmd: "status" });
|
|
182
|
+
expect(cmd.cmd).toBe("status");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("rejects launch without required fields", () => {
|
|
186
|
+
expect(() => DashboardCommand.parse({ cmd: "launch" })).toThrow(); // missing taskId, prompt, config
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("rejects launch with invalid role", () => {
|
|
190
|
+
expect(() =>
|
|
191
|
+
DashboardCommand.parse({
|
|
192
|
+
cmd: "launch",
|
|
193
|
+
taskId: "t-1",
|
|
194
|
+
prompt: "test",
|
|
195
|
+
config: { role: "finance", cli: "opencode" }, // invalid role
|
|
196
|
+
})
|
|
197
|
+
).toThrow();
|
|
198
|
+
});
|
|
199
|
+
});
|
package/src/schemas.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// ─── Agent Roles ────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export const AgentRole = z.enum([
|
|
6
|
+
"architecture",
|
|
7
|
+
"development",
|
|
8
|
+
"testing",
|
|
9
|
+
"documentation",
|
|
10
|
+
]);
|
|
11
|
+
export type AgentRole = z.infer<typeof AgentRole>;
|
|
12
|
+
|
|
13
|
+
// ─── Subagent Configuration ──────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export const SubagentConfig = z.object({
|
|
16
|
+
role: AgentRole,
|
|
17
|
+
/** The CLI tool to use for this role (e.g. "opencode", "gemini", "qwen") */
|
|
18
|
+
cli: z.string(),
|
|
19
|
+
/** Extra args to pass to the CLI tool */
|
|
20
|
+
args: z.array(z.string()).default([]),
|
|
21
|
+
/** Working directory override. Defaults to monorepo root. */
|
|
22
|
+
cwd: z.string().optional(),
|
|
23
|
+
/** Max wallclock seconds before the process is auto-killed */
|
|
24
|
+
timeoutSeconds: z.number().default(600),
|
|
25
|
+
});
|
|
26
|
+
export type SubagentConfig = z.infer<typeof SubagentConfig>;
|
|
27
|
+
|
|
28
|
+
// ─── Telemetry event shapes sent over the local WebSocket mesh ──────────────
|
|
29
|
+
|
|
30
|
+
export const TelemetryEventType = z.enum([
|
|
31
|
+
"agent:started",
|
|
32
|
+
"agent:output", // stdout/stderr chunk
|
|
33
|
+
"agent:progress", // 0-100 progress hint emitted by the CLI tool
|
|
34
|
+
"agent:token_usage", // token counters
|
|
35
|
+
"agent:finished",
|
|
36
|
+
"agent:error",
|
|
37
|
+
"agent:cancelled",
|
|
38
|
+
"orchestrator:status",
|
|
39
|
+
]);
|
|
40
|
+
export type TelemetryEventType = z.infer<typeof TelemetryEventType>;
|
|
41
|
+
|
|
42
|
+
const BaseEvent = z.object({
|
|
43
|
+
ts: z.number().default(() => Date.now()),
|
|
44
|
+
role: AgentRole,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const AgentStartedEvent = BaseEvent.extend({
|
|
48
|
+
type: z.literal("agent:started"),
|
|
49
|
+
pid: z.number(),
|
|
50
|
+
cli: z.string(),
|
|
51
|
+
taskId: z.string(),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export const AgentOutputEvent = BaseEvent.extend({
|
|
55
|
+
type: z.literal("agent:output"),
|
|
56
|
+
taskId: z.string(),
|
|
57
|
+
stream: z.enum(["stdout", "stderr"]),
|
|
58
|
+
chunk: z.string(),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export const AgentProgressEvent = BaseEvent.extend({
|
|
62
|
+
type: z.literal("agent:progress"),
|
|
63
|
+
taskId: z.string(),
|
|
64
|
+
percent: z.number().min(0).max(100),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export const AgentTokenUsageEvent = BaseEvent.extend({
|
|
68
|
+
type: z.literal("agent:token_usage"),
|
|
69
|
+
taskId: z.string(),
|
|
70
|
+
inputTokens: z.number(),
|
|
71
|
+
outputTokens: z.number(),
|
|
72
|
+
model: z.string(),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export const AgentFinishedEvent = BaseEvent.extend({
|
|
76
|
+
type: z.literal("agent:finished"),
|
|
77
|
+
taskId: z.string(),
|
|
78
|
+
exitCode: z.number(),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export const AgentErrorEvent = BaseEvent.extend({
|
|
82
|
+
type: z.literal("agent:error"),
|
|
83
|
+
taskId: z.string(),
|
|
84
|
+
message: z.string(),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
export const AgentCancelledEvent = BaseEvent.extend({
|
|
88
|
+
type: z.literal("agent:cancelled"),
|
|
89
|
+
taskId: z.string(),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
export const OrchestratorStatusEvent = z.object({
|
|
93
|
+
type: z.literal("orchestrator:status"),
|
|
94
|
+
ts: z.number().default(() => Date.now()),
|
|
95
|
+
agents: z.array(
|
|
96
|
+
z.object({
|
|
97
|
+
taskId: z.string(),
|
|
98
|
+
role: AgentRole,
|
|
99
|
+
cli: z.string(),
|
|
100
|
+
pid: z.number(),
|
|
101
|
+
state: z.enum(["running", "finished", "cancelled", "error"]),
|
|
102
|
+
progress: z.number(),
|
|
103
|
+
tokens: z.object({ input: z.number(), output: z.number() }),
|
|
104
|
+
model: z.string().optional(),
|
|
105
|
+
})
|
|
106
|
+
),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
export const TelemetryEvent = z.discriminatedUnion("type", [
|
|
110
|
+
AgentStartedEvent,
|
|
111
|
+
AgentOutputEvent,
|
|
112
|
+
AgentProgressEvent,
|
|
113
|
+
AgentTokenUsageEvent,
|
|
114
|
+
AgentFinishedEvent,
|
|
115
|
+
AgentErrorEvent,
|
|
116
|
+
AgentCancelledEvent,
|
|
117
|
+
OrchestratorStatusEvent,
|
|
118
|
+
]);
|
|
119
|
+
export type TelemetryEvent = z.infer<typeof TelemetryEvent>;
|
|
120
|
+
|
|
121
|
+
// ─── Dashboard → Orchestrator commands ──────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
export const DashboardCommand = z.discriminatedUnion("cmd", [
|
|
124
|
+
z.object({
|
|
125
|
+
cmd: z.literal("launch"),
|
|
126
|
+
config: SubagentConfig,
|
|
127
|
+
taskId: z.string(),
|
|
128
|
+
prompt: z.string(),
|
|
129
|
+
}),
|
|
130
|
+
z.object({ cmd: z.literal("cancel"), taskId: z.string() }),
|
|
131
|
+
z.object({ cmd: z.literal("status") }),
|
|
132
|
+
]);
|
|
133
|
+
export type DashboardCommand = z.infer<typeof DashboardCommand>;
|