@hueyexe/opencode-ensemble 0.9.1 → 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.
@@ -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
  }
@@ -114,6 +122,8 @@ const plugin: Plugin = async (input) => {
114
122
  stallMinSteps: config.stallMinSteps,
115
123
  stallTokenThreshold: config.stallTokenThreshold,
116
124
  cwd: input.directory,
125
+ peerMessageLimit: config.peerMessageLimit,
126
+ peerMessageWindowMs: config.peerMessageWindowMs,
117
127
  })
118
128
  watchdog.start()
119
129
 
@@ -198,6 +208,29 @@ const plugin: Plugin = async (input) => {
198
208
  })
199
209
  }
200
210
  }
211
+
212
+ // Also flush peer messages for teammates that just went idle
213
+ // Only flush messages older than 5s to avoid double-delivery with the direct promptAsync path
214
+ const member = db.query(
215
+ `SELECT tm.team_id, tm.name FROM team_member tm
216
+ JOIN team t ON tm.team_id = t.id
217
+ WHERE tm.session_id = ? AND t.status = 'active'`
218
+ ).get(sessionID) as { team_id: string; name: string } | null
219
+ if (member) {
220
+ const staleThreshold = Date.now() - 5000
221
+ const peerMsgs = db.query(
222
+ "SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND to_name = ? AND delivered = 0 AND time_created < ?"
223
+ ).get(member.team_id, member.name, staleThreshold) as { c: number }
224
+ if (peerMsgs.c > 0) {
225
+ log(`wake-peer: ${member.name} has ${peerMsgs.c} pending peer messages`)
226
+ client.session.promptAsync({
227
+ sessionID,
228
+ parts: [{ type: "text", text: `[System: ${peerMsgs.c} new message(s) from teammates]` }],
229
+ }).catch((err) => {
230
+ log(`wake-peer:failed err=${err instanceof Error ? err.message : String(err)}`)
231
+ })
232
+ }
233
+ }
201
234
  }
202
235
  }
203
236
 
@@ -317,6 +350,8 @@ const plugin: Plugin = async (input) => {
317
350
  const result = await executeTeamMessage(deps, args, ctx.sessionID)
318
351
  // Track message activity for stall detection
319
352
  progressTracker.recordMessage(ctx.sessionID)
353
+ // Track peer messages for chatty detection
354
+ if (args.to !== "lead") progressTracker.recordPeerMessage(ctx.sessionID)
320
355
  ctx.metadata({ title: `Message → ${args.to}` })
321
356
  return result
322
357
  },
package/src/progress.ts CHANGED
@@ -12,7 +12,9 @@ export class ProgressTracker {
12
12
  private steps = new Map<string, StepRecord[]>()
13
13
  private lastMessageAt = new Map<string, number>()
14
14
  private lastTaskAt = new Map<string, number>()
15
+ private peerMessages = new Map<string, number[]>()
15
16
  private reported = new Set<string>()
17
+ private chattyReported = new Set<string>()
16
18
  private readonly maxSteps: number
17
19
 
18
20
  constructor(maxSteps = 10) {
@@ -27,6 +29,40 @@ export class ProgressTracker {
27
29
  this.steps.set(sessionId, records)
28
30
  }
29
31
 
32
+ /** Record a peer message (not to lead). Used for chatty detection. */
33
+ recordPeerMessage(sessionId: string): void {
34
+ const timestamps = this.peerMessages.get(sessionId) ?? []
35
+ timestamps.push(Date.now())
36
+ this.peerMessages.set(sessionId, timestamps)
37
+ }
38
+
39
+ /** Check if agent is chatty: more than `limit` peer messages within `windowMs`. */
40
+ isChatty(sessionId: string, limit: number, windowMs: number): boolean {
41
+ if (limit === 0) return false
42
+ const timestamps = this.peerMessages.get(sessionId)
43
+ if (!timestamps) return false
44
+ const cutoff = Date.now() - windowMs
45
+ const recent = timestamps.filter(t => t >= cutoff)
46
+ // Prune old timestamps
47
+ this.peerMessages.set(sessionId, recent)
48
+ return recent.length >= limit
49
+ }
50
+
51
+ /** Mark chatty as reported (avoid spam). */
52
+ markChattyReported(sessionId: string): void {
53
+ this.chattyReported.add(sessionId)
54
+ }
55
+
56
+ /** Check if chatty already reported. */
57
+ isChattyReported(sessionId: string): boolean {
58
+ return this.chattyReported.has(sessionId)
59
+ }
60
+
61
+ /** Clear chatty report (e.g., when agent sends result to lead). */
62
+ clearChattyReport(sessionId: string): void {
63
+ this.chattyReported.delete(sessionId)
64
+ }
65
+
30
66
  /** Record that this member sent a team_message. Clears stall report. */
31
67
  recordMessage(sessionId: string): void {
32
68
  this.lastMessageAt.set(sessionId, Date.now())
@@ -80,6 +116,8 @@ export class ProgressTracker {
80
116
  this.steps.delete(sessionId)
81
117
  this.lastMessageAt.delete(sessionId)
82
118
  this.lastTaskAt.delete(sessionId)
119
+ this.peerMessages.delete(sessionId)
83
120
  this.reported.delete(sessionId)
121
+ this.chattyReported.delete(sessionId)
84
122
  }
85
123
  }
@@ -125,13 +125,39 @@ export function buildLeadSystemPrompt(db: Database, teamId: string): string {
125
125
 
126
126
  /**
127
127
  * Build the system prompt injected into a teammate's session.
128
- * Short role reminder with name, team name, and communication tool.
128
+ * Includes role reminder and delivers any pending peer messages.
129
129
  */
130
130
  export function buildTeammateSystemPrompt(db: Database, teamId: string, memberName: string): string {
131
131
  const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId) as { name: string } | null
132
132
  if (!team) return ""
133
133
 
134
- return `You are "${memberName}", a teammate in team "${team.name}". Use team_message to communicate. You MUST send your results to the lead via team_message before stopping.`
134
+ const lines = [
135
+ `You are "${memberName}", a teammate in team "${team.name}". Use team_message to communicate. You MUST send your results to the lead via team_message before stopping.`,
136
+ ]
137
+
138
+ // Deliver pending peer messages addressed to this teammate
139
+ const pendingMessages = db.query(
140
+ "SELECT id, from_name, content FROM team_message WHERE team_id = ? AND to_name = ? AND delivered = 0 ORDER BY time_created ASC"
141
+ ).all(teamId, memberName) as Array<{ id: string; from_name: string; content: string }>
142
+
143
+ if (pendingMessages.length > 0) {
144
+ lines.push("", "--- Messages for you ---")
145
+ const MAX_MSG = 500
146
+ for (const msg of pendingMessages) {
147
+ const parsed = parseTaskResult(msg.content)
148
+ if (parsed) {
149
+ lines.push(formatTaskResult(msg.from_name, { ...parsed, details: truncate(parsed.details, MAX_MSG) }))
150
+ } else if (msg.content.length > MAX_MSG) {
151
+ lines.push(`[From ${msg.from_name}]: ${msg.content.slice(0, MAX_MSG)}... (truncated)`)
152
+ } else {
153
+ lines.push(`[From ${msg.from_name}]: ${msg.content}`)
154
+ }
155
+ markDelivered(db, msg.id)
156
+ }
157
+ lines.push("--- End Messages ---")
158
+ }
159
+
160
+ return lines.join("\n")
135
161
  }
136
162
 
137
163
  /**
@@ -18,6 +18,22 @@ export async function executeTeamMessage(
18
18
  const senderName = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
19
19
 
20
20
  const recipientSessionId = resolveRecipientSession(deps.db, deps.registry, teamInfo.teamId, args.to)
21
+
22
+ // If recipient not found, store the message for later delivery (they may not be spawned yet)
23
+ // Reject approve/reject flags for unspawned recipients — plan approval requires the member to exist
24
+ if (!recipientSessionId && args.to !== "lead") {
25
+ if (args.approve || args.reject) {
26
+ throw new Error(`Cannot approve/reject plan for "${args.to}" — they haven't been spawned yet.`)
27
+ }
28
+ sendMessage(deps.db, {
29
+ teamId: teamInfo.teamId,
30
+ from: senderName,
31
+ to: args.to,
32
+ content: args.text,
33
+ })
34
+ log(`team_message:queued from=${senderName} to=${args.to} (recipient not yet spawned)`)
35
+ return `Message queued for ${args.to} — they haven't been spawned yet. It will be delivered when they join the team.`
36
+ }
21
37
  if (!recipientSessionId) throw new Error(`Recipient "${args.to}" not found in team "${teamInfo.teamName}"`)
22
38
 
23
39
  // Handle plan approval/rejection
@@ -253,6 +253,19 @@ export async function executeTeamSpawn(
253
253
  )
254
254
  }
255
255
 
256
+ // Collaboration guidance for peer-to-peer communication
257
+ if (otherMembers.length > 0) {
258
+ context.push(
259
+ "",
260
+ "Collaboration:",
261
+ "- Check team_tasks_list to see what other teammates are working on.",
262
+ "- If you need information another teammate has, message them directly via team_message.",
263
+ "- If you discover something relevant to another teammate's task, share it with them.",
264
+ "- Use team_broadcast for updates that affect the whole team.",
265
+ "- Keep peer messages focused and actionable — coordinate, don't chat.",
266
+ )
267
+ }
268
+
256
269
  context.push("", "When you finish your task:")
257
270
  if (!isReadOnly && worktreeBranch) {
258
271
  context.push(`1. Commit your changes: git add -A && git commit -m "your summary"`)
package/src/watchdog.ts CHANGED
@@ -24,6 +24,10 @@ interface WatchdogOpts {
24
24
  stallTokenThreshold?: number
25
25
  /** Project directory for git operations. */
26
26
  cwd?: string
27
+ /** Max peer messages per agent per window before nudge. 0 disables. */
28
+ peerMessageLimit?: number
29
+ /** Time window for peer message rate limiting in ms. */
30
+ peerMessageWindowMs?: number
27
31
  }
28
32
 
29
33
  /**
@@ -41,6 +45,8 @@ export class Watchdog {
41
45
  private readonly stallMinSteps: number
42
46
  private readonly stallTokenThreshold: number
43
47
  private readonly cwd?: string
48
+ private readonly peerMessageLimit: number
49
+ private readonly peerMessageWindowMs: number
44
50
  private timer: ReturnType<typeof setInterval> | undefined
45
51
 
46
52
  constructor(opts: WatchdogOpts) {
@@ -54,6 +60,8 @@ export class Watchdog {
54
60
  this.stallMinSteps = opts.stallMinSteps ?? 3
55
61
  this.stallTokenThreshold = opts.stallTokenThreshold ?? 500
56
62
  this.cwd = opts.cwd
63
+ this.peerMessageLimit = opts.peerMessageLimit ?? 0
64
+ this.peerMessageWindowMs = opts.peerMessageWindowMs ?? 300_000
57
65
  }
58
66
 
59
67
  private static STALE_THRESHOLD_MS = Number(process.env.STALE_WORKTREE_THRESHOLD_MS) || 300_000
@@ -133,10 +141,46 @@ export class Watchdog {
133
141
  }
134
142
  }
135
143
 
144
+ /** Check for chatty agents sending too many peer messages. */
145
+ async checkChatty(): Promise<void> {
146
+ if (!this.progressTracker || this.peerMessageLimit === 0) return
147
+
148
+ const busy = this.db.query(
149
+ `SELECT tm.team_id, tm.name, tm.session_id
150
+ FROM team_member tm
151
+ JOIN team t ON tm.team_id = t.id
152
+ WHERE t.status = 'active' AND tm.status = 'busy'`
153
+ ).all() as Array<{ team_id: string; name: string; session_id: string }>
154
+
155
+ for (const member of busy) {
156
+ if (this.progressTracker.isChattyReported(member.session_id)) continue
157
+ if (!this.progressTracker.isChatty(member.session_id, this.peerMessageLimit, this.peerMessageWindowMs)) continue
158
+
159
+ this.progressTracker.markChattyReported(member.session_id)
160
+
161
+ // Nudge the agent
162
+ this.client.session.promptAsync({
163
+ sessionID: member.session_id,
164
+ parts: [{ type: "text", text: "[System]: You've sent several messages to teammates. Focus on completing your task and send your results to the lead via team_message." }],
165
+ }).catch(() => { /* best effort */ })
166
+
167
+ // Notify the lead
168
+ sendMessage(this.db, {
169
+ teamId: member.team_id,
170
+ from: "system",
171
+ to: "lead",
172
+ content: `Agent "${member.name}" is sending many peer messages and may be over-coordinating. Consider checking on them.`,
173
+ })
174
+
175
+ log(`watchdog:chatty member=${member.name} limit=${this.peerMessageLimit}`)
176
+ }
177
+ }
178
+
136
179
  /** Run a single check for stale busy members. */
137
180
  async check(): Promise<void> {
138
181
  await this.cleanupStaleWorktrees()
139
182
  await this.checkStalled()
183
+ await this.checkChatty()
140
184
  if (this.ttlMs === 0) return
141
185
 
142
186
  const cutoff = Date.now() - this.ttlMs