@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 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>;