@hueyexe/opencode-ensemble 0.9.1 → 0.10.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.
@@ -0,0 +1,166 @@
1
+ import type { Database } from "bun:sqlite"
2
+ import { DASHBOARD_HEAD } from "./dashboard-html"
3
+ import { DASHBOARD_JS_PART1 } from "./dashboard-js-part1"
4
+ import { DASHBOARD_JS_PART2 } from "./dashboard-js-part2"
5
+ import { DASHBOARD_JS_PART3 } from "./dashboard-js-part3"
6
+ import { log } from "./log"
7
+
8
+ /** Assemble the full dashboard HTML from parts. */
9
+ const DASHBOARD_HTML = DASHBOARD_HEAD + "\n<script>" + DASHBOARD_JS_PART1 + DASHBOARD_JS_PART2 + DASHBOARD_JS_PART3 + "<\/script>\n</body></html>"
10
+
11
+ interface TeamRow {
12
+ id: string
13
+ name: string
14
+ status: string
15
+ lead_agent: string | null
16
+ time_created: number
17
+ time_updated: number
18
+ }
19
+
20
+ interface MemberRow {
21
+ name: string
22
+ agent: string
23
+ status: string
24
+ execution_status: string
25
+ worktree_branch: string | null
26
+ prompt: string | null
27
+ model: string | null
28
+ plan_approval: string
29
+ time_created: number
30
+ time_updated: number
31
+ }
32
+
33
+ interface TaskRow {
34
+ id: string
35
+ content: string
36
+ status: string
37
+ priority: string
38
+ assignee: string | null
39
+ depends_on: string | null
40
+ time_created: number
41
+ time_updated: number
42
+ }
43
+
44
+ interface MessageRow {
45
+ id: string
46
+ from_name: string
47
+ to_name: string | null
48
+ content: string
49
+ delivered: number
50
+ read: number
51
+ time_created: number
52
+ }
53
+
54
+ function jsonResponse(data: unknown, status = 200): Response {
55
+ return new Response(JSON.stringify(data), {
56
+ status,
57
+ headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
58
+ })
59
+ }
60
+
61
+ function buildState(db: Database): { teams: unknown[] } {
62
+ const teams = db.query("SELECT id, name, status, lead_agent, time_created, time_updated FROM team ORDER BY time_created DESC").all() as TeamRow[]
63
+ const memberStmt = db.query("SELECT name, agent, status, execution_status, worktree_branch, prompt, model, plan_approval, time_created, time_updated FROM team_member WHERE team_id = ?")
64
+ const taskStmt = db.query("SELECT id, content, status, priority, assignee, depends_on, time_created, time_updated FROM team_task WHERE team_id = ?")
65
+ const msgStmt = db.query("SELECT id, from_name, to_name, content, delivered, read, time_created FROM team_message WHERE team_id = ? ORDER BY time_created DESC LIMIT 50")
66
+
67
+ return {
68
+ teams: teams.map((t) => ({
69
+ id: t.id,
70
+ name: t.name,
71
+ status: t.status,
72
+ leadAgent: t.lead_agent,
73
+ timeCreated: t.time_created,
74
+ timeUpdated: t.time_updated,
75
+ members: (memberStmt.all(t.id) as MemberRow[]).map((m) => ({
76
+ name: m.name,
77
+ agent: m.agent,
78
+ status: m.status,
79
+ executionStatus: m.execution_status,
80
+ worktreeBranch: m.worktree_branch,
81
+ prompt: m.prompt,
82
+ model: m.model,
83
+ planApproval: m.plan_approval,
84
+ timeCreated: m.time_created,
85
+ timeUpdated: m.time_updated,
86
+ })),
87
+ tasks: (taskStmt.all(t.id) as TaskRow[]).map((tk) => ({
88
+ id: tk.id,
89
+ content: tk.content,
90
+ status: tk.status,
91
+ priority: tk.priority,
92
+ assignee: tk.assignee,
93
+ dependsOn: tk.depends_on,
94
+ timeCreated: tk.time_created,
95
+ timeUpdated: tk.time_updated,
96
+ })),
97
+ messages: (msgStmt.all(t.id) as MessageRow[]).map((msg) => ({
98
+ id: msg.id,
99
+ fromName: msg.from_name,
100
+ toName: msg.to_name,
101
+ content: msg.content,
102
+ delivered: msg.delivered === 1,
103
+ read: msg.read === 1,
104
+ timeCreated: msg.time_created,
105
+ })),
106
+ })),
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Start the dashboard HTTP server.
112
+ * Serves a JSON API for team state and the dashboard HTML.
113
+ * Singleton: if the port is already in use by another ensemble instance, skips silently.
114
+ * Returns the server instance, or null if skipped.
115
+ */
116
+ export async function startDashboard(db: Database, port: number): Promise<ReturnType<typeof Bun.serve> | null> {
117
+ try {
118
+ const server = Bun.serve({
119
+ port,
120
+ fetch(req) {
121
+ const url = new URL(req.url)
122
+
123
+ if (url.pathname === "/api/health") {
124
+ return jsonResponse({ ensemble: true, pid: process.pid })
125
+ }
126
+
127
+ if (url.pathname === "/api/state") {
128
+ return jsonResponse(buildState(db))
129
+ }
130
+
131
+ if (url.pathname === "/") {
132
+ return new Response(DASHBOARD_HTML, {
133
+ headers: { "Content-Type": "text/html" },
134
+ })
135
+ }
136
+
137
+ return new Response("Not Found", { status: 404 })
138
+ },
139
+ })
140
+ log(`dashboard:started port=${port} url=http://localhost:${port}`)
141
+ return server
142
+ } catch (err) {
143
+ if (err && typeof err === "object" && "code" in err && err.code === "EADDRINUSE") {
144
+ try {
145
+ const res = await fetch(`http://localhost:${port}/api/health`)
146
+ const data = await res.json() as { ensemble?: boolean; pid?: number }
147
+ if (data.ensemble && data.pid) {
148
+ // Check if the other process is still alive
149
+ let alive = false
150
+ try { process.kill(data.pid, 0); alive = true } catch { /* process is dead */ }
151
+ if (alive && data.pid !== process.pid) {
152
+ log(`dashboard:already-running port=${port} pid=${data.pid}`)
153
+ return null
154
+ }
155
+ // Stale server from a dead process — warn the user
156
+ log(`dashboard:stale-server port=${port} stale-pid=${data.pid} — run: kill -9 ${data.pid} || lsof -ti:${port} | xargs kill -9`)
157
+ return null
158
+ }
159
+ } catch { /* health check failed — port held by something else */ }
160
+ log(`dashboard:port-in-use port=${port} (not an ensemble instance)`)
161
+ return null
162
+ }
163
+ log(`dashboard:failed err=${err instanceof Error ? err.message : String(err)}`)
164
+ return null
165
+ }
166
+ }
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ import { log, initLog } from "./log"
15
15
  import { findTeamBySession } from "./types"
16
16
  import { loadConfig } from "./config"
17
17
  import { ProgressTracker } from "./progress"
18
+ import { startDashboard } from "./dashboard"
18
19
  import { executeTeamCreate } from "./tools/team-create"
19
20
  import { executeTeamSpawn } from "./tools/team-spawn"
20
21
  import { executeTeamMessage } from "./tools/team-message"
@@ -93,6 +94,13 @@ const plugin: Plugin = async (input) => {
93
94
  log(`init:recover-branches:failed err=${err instanceof Error ? err.message : String(err)}`)
94
95
  })
95
96
  log("init:recovery:done")
97
+
98
+ // Start dashboard server (main instance only, not worktree instances)
99
+ if (config.dashboardPort !== 0) {
100
+ startDashboard(db, config.dashboardPort).catch((err) => {
101
+ log(`init:dashboard:failed err=${err instanceof Error ? err.message : String(err)}`)
102
+ })
103
+ }
96
104
  } else {
97
105
  log(`init:skip-recovery (worktree instance: ${input.directory})`)
98
106
  }