@hueyexe/opencode-ensemble 0.14.1 → 0.15.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hueyexe/opencode-ensemble",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "Agent teams for OpenCode — parallel agents with peer-to-peer communication, shared tasks, and coordinated execution",
5
5
  "module": "src/index.ts",
6
6
  "main": "dist/index.js",
@@ -30,9 +30,13 @@
30
30
  "lint": "bunx biome lint src/",
31
31
  "typecheck": "tsc --noEmit",
32
32
  "test": "bun test",
33
- "build": "bun build src/index.ts --outdir dist --target bun",
33
+ "build": "bun build src/index.ts --outdir dist --target node",
34
34
  "prepublishOnly": "bun run typecheck && bun test && bun run build"
35
35
  },
36
+ "engines": {
37
+ "node": ">=24",
38
+ "bun": ">=1.0"
39
+ },
36
40
  "devDependencies": {
37
41
  "@biomejs/biome": "^2.4.9",
38
42
  "@types/bun": "latest"
@@ -41,7 +45,7 @@
41
45
  "typescript": "^5"
42
46
  },
43
47
  "dependencies": {
44
- "@opencode-ai/plugin": "^1.14.45",
45
- "@opencode-ai/sdk": "^1.14.45"
48
+ "@opencode-ai/plugin": "^1.15.1",
49
+ "@opencode-ai/sdk": "^1.15.1"
46
50
  }
47
51
  }
@@ -27,6 +27,11 @@ export const DASHBOARD_HEAD = `<!DOCTYPE html>
27
27
  details summary::-webkit-details-marker{display:none}details summary{list-style:none}
28
28
  :focus-visible{outline:2px solid rgba(96,165,250,.85);outline-offset:2px}
29
29
  select{-webkit-appearance:none;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 24 24' fill='none' stroke='%235e6a82' stroke-width='2.5'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 8px center;padding-right:22px}
30
+ .project-link[aria-current="true"]{color:#e2e8f0}
31
+ .team-link[aria-current="true"]{color:#e2e8f0;border-left-color:#22c55e;background:rgba(34,197,94,.06)}
32
+ #content.nav-collapsed{grid-template-columns:2rem minmax(0,1fr)}
33
+ #projects[hidden]{display:none!important}
34
+ #project-rail[hidden]{display:none!important}
30
35
  .xp{max-height:0;overflow:hidden;transition:max-height .3s ease-out}.xp-open{max-height:3000px;transition:max-height .5s ease-in}
31
36
  .card-sel{outline:2px solid rgba(59,130,246,.4);outline-offset:1px}
32
37
  .md pre{background:#1a1f2e;border:1px solid #1e2433;border-radius:6px;padding:8px 12px;overflow-x:auto;margin:6px 0;font-size:12px;line-height:1.5}
@@ -47,8 +52,7 @@ select{-webkit-appearance:none;appearance:none;background-image:url("data:image/
47
52
  <header class="fixed top-0 inset-x-0 h-11 bg-base-950/95 backdrop-blur border-b border-base-800 flex items-center justify-between px-3 sm:px-4 z-50">
48
53
  <div class="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
49
54
  <span class="font-mono font-semibold text-[13px] tracking-[.08em] text-txt-200">ensemble</span>
50
- <span class="text-base-700">|</span>
51
- <select id="sel" class="bg-base-900 border border-base-700 rounded-md px-2 py-[3px] text-[11px] text-txt-200 font-mono outline-none cursor-pointer hover:border-base-600 transition-colors max-w-[180px] sm:max-w-[320px] min-w-0"></select>
55
+ <span id="crumb" class="text-[11px] text-txt-500 font-mono truncate"></span>
52
56
  </div>
53
57
  <div class="flex items-center gap-2 sm:gap-4 shrink-0">
54
58
  <div id="hring" class="w-6 h-6 rounded-full" title="Team health"></div>
@@ -67,7 +71,10 @@ select{-webkit-appearance:none;appearance:none;background-image:url("data:image/
67
71
  <div class="text-txt-400 text-sm">Waiting for a team</div>
68
72
  <div class="text-txt-500 text-[11px]">Run <code class="px-1.5 py-0.5 bg-base-900 rounded text-txt-300 font-mono text-[11px]">team_create</code> in OpenCode to get started</div>
69
73
  </div>
70
- <div id="content" class="hidden max-w-[1600px] mx-auto">
74
+ <div id="content" class="hidden max-w-[1720px] mx-auto grid grid-cols-1 lg:grid-cols-[260px_minmax(0,1fr)] gap-4 items-start">
75
+ <div id="project-rail" hidden class="lg:sticky lg:top-[88px]"><button id="nav-expand" type="button" aria-label="Show project navigation" aria-controls="projects" aria-expanded="false" class="h-8 w-8 rounded border border-base-800 text-txt-500 hover:text-txt-200 hover:border-base-700 transition-colors">&gt;</button></div>
76
+ <aside id="projects" aria-label="Project navigation" class="bg-base-950/60 border-r border-base-800/70 pr-3 lg:sticky lg:top-[88px]"></aside>
77
+ <div class="min-w-0">
71
78
  <section id="attention" aria-label="Team attention" class="mb-3"></section>
72
79
  <div class="grid grid-cols-1 xl:grid-cols-[minmax(360px,1.35fr)_minmax(320px,.8fr)] gap-4 items-start">
73
80
  <section aria-label="Agent roster"><div id="agents" class="grid gap-2" style="grid-template-columns:repeat(auto-fill,minmax(300px,1fr))"></div></section>
@@ -77,6 +84,7 @@ select{-webkit-appearance:none;appearance:none;background-image:url("data:image/
77
84
  </div>
78
85
  </div>
79
86
  </div>
87
+ </div>
80
88
  </main>
81
89
  <div id="tl" aria-label="Event timeline" class="fixed bottom-0 inset-x-0 h-10 bg-base-900/90 backdrop-blur border-t border-base-800 px-4 flex items-center z-40 overflow-x-auto scroll hidden"></div>
82
90
  <div id="sco" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="shortcuts-title" tabindex="-1" onclick="closeShortcuts()">
@@ -1,6 +1,6 @@
1
1
  /** Dashboard JS — utilities, data helpers, and state management. */
2
- export const DASHBOARD_JS_PART1 = `
3
- let S=null,selId=null,fails=0,pollT=Date.now(),prevMC=0,selCard=-1;
2
+ export const DASHBOARD_JS_CORE = `
3
+ let S=null,selId=null,selProjectId=null,fails=0,pollT=Date.now(),prevMC=0,selCard=-1,navCollapsed=false;
4
4
  const expCards=new Set(),expMsgs=new Set();
5
5
  const E=s=>s?String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'):'';
6
6
  const D=ms=>{const s=Math.floor(Math.abs(ms)/1000);return s<60?s+'s':s<3600?Math.floor(s/60)+'m':Math.floor(s/3600)+'h'};
@@ -63,13 +63,21 @@ function allTeams(){
63
63
  const archived=[...S.teams.filter(t=>t.status!=='active')].sort((a,b)=>b.timeUpdated-a.timeUpdated);
64
64
  return{active,archived};
65
65
  }
66
- function cur(){const{active,archived}=allTeams(),all=[...active,...archived];if(!all.length)return null;if(selId){const t=all.find(t=>t.id===selId);if(t)return t}return active[0]||all[0]}
66
+ function allProjects(){return S?.projects?[...S.projects].sort((a,b)=>b.timeUpdated-a.timeUpdated):[]}
67
+ function projectLabel(p){return p?(p.name||p.path||p.id):''}
68
+ function curProject(){const ps=allProjects();if(!ps.length)return null;if(selProjectId){const p=ps.find(p=>p.id===selProjectId);if(p)return p}var t=cur();return t?ps.find(p=>p.id===t.projectId)||ps[0]:ps[0]}
69
+ function cur(){const{active,archived}=allTeams(),all=[...active,...archived];if(!all.length)return null;if(selId){const t=all.find(t=>t.id===selId);if(t){selProjectId=t.projectId;return t}}const p=selProjectId&&S?.projects?.find(p=>p.id===selProjectId);const pt=p?[...(p.teams||[])].filter(t=>t.status==='active'):[ ];const t=pt.sort((a,b)=>b.timeUpdated-a.timeUpdated)[0]||active[0]||all[0];if(t){selId=t.id;selProjectId=t.projectId}return t}
67
70
 
68
71
  function deriveHealth(t){
69
72
  const mm=t.members||[];if(!mm.length)return{w:0,i:0,e:0,d:0,total:0};
70
73
  return{w:mm.filter(m=>m.status==='busy').length,i:mm.filter(m=>m.status==='ready').length,e:mm.filter(m=>m.status==='error').length,d:mm.filter(m=>m.status==='shutdown'||m.status==='shutdown_requested').length,total:mm.length};
71
74
  }
72
75
 
76
+ function coarseTeamStatus(t){const h=deriveHealth(t),blocked=(t.tasks||[]).filter(x=>x.status==='blocked').length;if(h.e)return{label:'error',color:'red',dot:'bg-red-500'};if(blocked)return{label:'blocked',color:'amber',dot:'bg-amber-500'};if(h.w)return{label:'working',color:'blue',dot:'bg-blue-500'};if(h.i)return{label:'idle',color:'muted',dot:'bg-txt-500'};return{label:t.status==='active'?'empty':t.status,color:'muted',dot:'bg-base-600'}}
77
+ function projectStatus(p){const teams=p.teams||[],counts={working:0,blocked:0,error:0,idle:0,done:0};teams.forEach(t=>{const s=coarseTeamStatus(t).label;if(s==='working')counts.working++;else if(s==='blocked')counts.blocked++;else if(s==='error')counts.error++;else if(s==='idle'||s==='empty')counts.idle++;else counts.done++});if(counts.error)return{label:'error',color:'red',dot:'bg-red-500',counts};if(counts.blocked)return{label:'blocked',color:'amber',dot:'bg-amber-500',counts};if(counts.working)return{label:'working',color:'blue',dot:'bg-blue-500',counts};return{label:'idle',color:'muted',dot:'bg-txt-500',counts}}
78
+ function statusTitleProject(p){const s=projectStatus(p),teams=p.teams||[];return projectLabel(p)+'\\nStatus: '+s.label+'\\nTeams: '+teams.length+'\\nWorking: '+s.counts.working+' · Blocked: '+s.counts.blocked+' · Error: '+s.counts.error+' · Idle: '+s.counts.idle}
79
+ function statusTitleTeam(t){const h=deriveHealth(t),tasks=t.tasks||[],blocked=tasks.filter(x=>x.status==='blocked').length,active=tasks.filter(x=>x.status==='in_progress').length,pending=tasks.filter(x=>x.status==='pending').length,done=tasks.filter(x=>x.status==='completed').length;return t.name+'\\nStatus: '+coarseTeamStatus(t).label+'\\nAgents: '+h.total+' total, '+h.w+' working, '+h.i+' idle, '+h.e+' error\\nTasks: '+active+' active, '+blocked+' blocked, '+pending+' pending, '+done+' done'}
80
+
73
81
  function lastMessageFor(name,msgs){return msgs.filter(m=>m.fromName===name||m.toName===name).sort((a,b)=>b.timeCreated-a.timeCreated)[0]||null}
74
82
  function activeTaskFor(name,tasks){return tasks.find(x=>x.assignee===name&&x.status==='in_progress')||tasks.find(x=>x.assignee===name&&x.status==='blocked')||null}
75
83
  function blockedTaskFor(name,tasks){return tasks.find(x=>x.assignee===name&&x.status==='blocked')||null}
@@ -1,15 +1,32 @@
1
1
  /** Dashboard JS — interaction handlers, keyboard, polling. */
2
- export const DASHBOARD_JS_PART3 = `
2
+ export const DASHBOARD_JS_EVENTS = `
3
3
  function toggleMsg(id){if(expMsgs.has(id))expMsgs.delete(id);else expMsgs.add(id);render()}
4
4
 
5
+ function applyNavCollapse(){
6
+ const content=document.getElementById('content'),projects=document.getElementById('projects'),rail=document.getElementById('project-rail'),toggle=document.getElementById('nav-toggle'),expand=document.getElementById('nav-expand');
7
+ content.classList.toggle('nav-collapsed',navCollapsed);
8
+ projects.hidden=navCollapsed;
9
+ projects.setAttribute('aria-hidden',String(navCollapsed));
10
+ rail.hidden=!navCollapsed;
11
+ if(toggle)toggle.setAttribute('aria-expanded',String(!navCollapsed));
12
+ expand.setAttribute('aria-expanded',String(!navCollapsed));
13
+ if(document.activeElement===toggle&&navCollapsed)expand.focus();
14
+ if(document.activeElement===expand&&!navCollapsed&&toggle)toggle.focus();
15
+ }
16
+
5
17
  function render(){
6
18
  rSel();const t=cur();
7
19
  const empty=document.getElementById('empty'),content=document.getElementById('content');
8
20
  if(!t){empty.classList.remove('hidden');empty.classList.add('flex');content.classList.add('hidden');document.getElementById('tl').classList.add('hidden');return}
9
21
  empty.classList.add('hidden');empty.classList.remove('flex');content.classList.remove('hidden');
22
+ applyNavCollapse();
23
+ const p=curProject();document.getElementById('crumb').textContent=p?' / '+projectLabel(p)+' / '+t.name:'';
10
24
  rHealth(t);rSum(t);rAttention(t);rAgents(t);rTasks(t);rActivity(t);rTimeline(t);
11
25
  }
12
26
 
27
+ function selectProject(id){selProjectId=id;const p=S?.projects?.find(p=>p.id===id);const t=(p?.teams||[]).filter(t=>t.status==='active').sort((a,b)=>b.timeUpdated-a.timeUpdated)[0]||(p?.teams||[])[0];if(t)selId=t.id;selCard=-1;render()}
28
+ function selectTeam(id){selId=id;selCard=-1;render()}
29
+
13
30
  function conn(ok){
14
31
  document.getElementById('cd').className='w-[7px] h-[7px] rounded-full '+(ok?'bg-emerald-500 pulse':'bg-red-500');
15
32
  document.getElementById('ct').textContent=ok?D(Date.now()-pollT)+' ago':'reconnecting to dashboard state';
@@ -56,6 +73,13 @@ setInterval(function(){var t=cur();if(t)rClock(t);if(fails<3)conn(true)},1000);
56
73
  // Poll every 2.5s
57
74
  setInterval(poll,2500);
58
75
 
76
+ document.addEventListener('click',function(e){
77
+ const id=e.target&&e.target.id;
78
+ if(id!=='nav-toggle'&&id!=='nav-expand')return;
79
+ navCollapsed=id==='nav-toggle';
80
+ applyNavCollapse();
81
+ });
82
+
59
83
  // Keyboard shortcuts
60
84
  document.addEventListener('keydown',function(e){
61
85
  const shortcutsOpen=document.getElementById('sco').classList.contains('show');
@@ -80,9 +104,6 @@ document.addEventListener('keydown',function(e){
80
104
  }
81
105
  });
82
106
 
83
- // Select handler
84
- document.getElementById('sel').addEventListener('change',function(){selId=this.value;render()});
85
-
86
107
  // Initial poll
87
108
  poll();
88
109
 
@@ -1,15 +1,20 @@
1
1
  /** Dashboard JS — render functions. */
2
- export const DASHBOARD_JS_PART2 = `
2
+ export const DASHBOARD_JS_RENDER = `
3
3
  function rSel(){
4
- const el=document.getElementById('sel'),{active,archived}=allTeams(),c=cur();
5
- if(!active.length&&!archived.length){el.innerHTML='<option>No teams</option>';return}
6
- let h='';
7
- if(active.length){h+='<optgroup label="Active">';h+=active.map(t=>'<option value="'+t.id+'">'+E(t.name)+' ('+relT(t.timeUpdated)+')</option>').join('');h+='</optgroup>'}
8
- if(archived.length){h+='<optgroup label="Archived">';h+=archived.slice(0,5).map(t=>'<option value="'+t.id+'">'+E(t.name)+'</option>').join('');if(archived.length>5)h+='<option disabled>+ '+(archived.length-5)+' more</option>';h+='</optgroup>'}
9
- if(el._lh!==h){el.innerHTML=h;el._lh=h}
10
- if(c)el.value=c.id;
4
+ const el=document.getElementById('projects'),ps=allProjects(),c=cur(),cp=curProject();
5
+ const head=renderProjectNavHeader();
6
+ if(!ps.length){patch(el,head+'<div class="text-center text-txt-500 text-[12px] py-6">No projects yet</div>');return}
7
+ let h='<nav class="text-[12px]" aria-label="Projects">'+head+'<div class="space-y-4">';
8
+ ps.forEach(function(p){h+=renderProjectSection(p,cp,c)});
9
+ h+='</div></nav>';
10
+ patch(el,h);
11
11
  }
12
12
 
13
+ function renderProjectNavHeader(){return '<div class="flex items-center justify-between gap-2 mb-3"><div class="text-[10px] uppercase tracking-[.18em] text-txt-500">Projects</div><button id="nav-toggle" type="button" aria-label="Hide project navigation" aria-controls="projects" aria-expanded="true" class="text-[10px] text-txt-500 border border-base-800 rounded px-1.5 py-[2px] hover:text-txt-200 hover:border-base-700 transition-colors">hide</button></div>'}
14
+ function renderProjectSection(p,cp,c){const teams=p.teams||[];return '<section>'+renderProjectButton(p,cp)+'<div class="mt-2 ml-[7px] border-l border-base-800/80">'+[...teams].sort((a,b)=>b.timeUpdated-a.timeUpdated).map(t=>renderTeamLink(t,c)).join('')+'</div></section>'}
15
+ function renderProjectButton(p,cp){const teams=p.teams||[],active=teams.filter(t=>t.status==='active').length,sel=cp&&cp.id===p.id,pss=projectStatus(p);return '<button type="button" aria-current="'+(sel?'true':'false')+'" title="'+E(statusTitleProject(p))+'" class="project-link group w-full text-left text-txt-300 hover:text-txt-100 transition-colors" data-project="'+E(p.id)+'" onclick="selectProject(this.dataset.project)"><div class="flex items-center gap-2 min-w-0"><span class="w-[5px] h-[5px] rounded-full '+pss.dot+(pss.label==='working'?' pulse':'')+' shrink-0"></span><span class="font-mono font-semibold truncate">'+E(projectLabel(p))+'</span>'+chip(pss.label,pss.color)+'</div><div class="mt-1 ml-3 text-[10px] text-txt-500">'+active+' team'+(active!==1?'s':'')+'</div></button>'}
16
+ function renderTeamLink(t,c){const ss=coarseTeamStatus(t),tsel=c&&c.id===t.id;return '<button type="button" title="'+E(statusTitleTeam(t))+'" class="team-link block w-full text-left border-l-2 border-transparent -ml-px py-1.5 pl-3 pr-2 text-[11px] text-txt-500 hover:text-txt-200 hover:bg-base-900/70 transition-colors" aria-current="'+(tsel?'true':'false')+'" data-team="'+E(t.id)+'" onclick="selectTeam(this.dataset.team)"><div class="flex items-center gap-2 min-w-0"><span class="w-[5px] h-[5px] rounded-full '+ss.dot+(ss.label==='working'?' pulse':'')+' shrink-0"></span><span class="truncate font-mono">'+E(t.name)+'</span><span class="ml-auto text-[9px] uppercase tracking-wide text-txt-500">'+E(ss.label)+'</span></div></button>'}
17
+
13
18
  function rHealth(t){
14
19
  const el=document.getElementById('hring'),h=deriveHealth(t);
15
20
  if(!h.total){el.style.background='conic-gradient(#2a3144 0deg,#2a3144 360deg)';return}
package/src/dashboard.ts CHANGED
@@ -1,22 +1,34 @@
1
- import type { Database } from "bun:sqlite"
1
+ import { createServer } from "node:http"
2
+ import type { IncomingMessage, Server, ServerResponse } from "node:http"
3
+ import type { Database } from "./db"
2
4
  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"
5
+ import { DASHBOARD_JS_CORE } from "./dashboard-js-core"
6
+ import { DASHBOARD_JS_EVENTS } from "./dashboard-js-events"
7
+ import { DASHBOARD_JS_RENDER } from "./dashboard-js-render"
6
8
  import { log } from "./log"
7
9
 
8
10
  /** 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>"
11
+ const DASHBOARD_HTML = DASHBOARD_HEAD + "\n<script>" + DASHBOARD_JS_CORE + DASHBOARD_JS_RENDER + DASHBOARD_JS_EVENTS + "<\/script>\n</body></html>"
10
12
 
11
13
  interface TeamRow {
12
14
  id: string
13
15
  name: string
16
+ project_id: string
14
17
  status: string
15
18
  lead_agent: string | null
16
19
  time_created: number
17
20
  time_updated: number
18
21
  }
19
22
 
23
+ interface ProjectRow {
24
+ id: string
25
+ name: string
26
+ path: string
27
+ status: string
28
+ time_created: number
29
+ time_updated: number
30
+ }
31
+
20
32
  interface MemberRow {
21
33
  name: string
22
34
  agent: string
@@ -51,13 +63,6 @@ interface MessageRow {
51
63
  time_created: number
52
64
  }
53
65
 
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
66
  function parseDependsOn(value: string | null): string[] {
62
67
  if (!value) return []
63
68
 
@@ -73,32 +78,35 @@ function parseDependsOn(value: string | null): string[] {
73
78
  return []
74
79
  }
75
80
 
76
- function buildState(db: Database): { teams: unknown[] } {
77
- const teams = db.query("SELECT id, name, status, lead_agent, time_created, time_updated FROM team ORDER BY time_created DESC").all() as TeamRow[]
81
+ function buildState(db: Database): { projects: unknown[]; teams: unknown[] } {
82
+ const projects = db.query("SELECT id, name, path, status, time_created, time_updated FROM project ORDER BY time_updated DESC").all() as ProjectRow[]
83
+ const teams = db.query("SELECT id, name, project_id, status, lead_agent, time_created, time_updated FROM team ORDER BY time_created DESC").all() as TeamRow[]
78
84
  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 = ?")
79
85
  const taskStmt = db.query("SELECT id, content, status, priority, assignee, depends_on, time_created, time_updated FROM team_task WHERE team_id = ?")
80
86
  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")
81
87
 
82
- return {
83
- teams: teams.map((t) => ({
88
+ const mappedTeams = teams.map((t) => {
89
+ const members = (memberStmt.all(t.id) as MemberRow[]).map((m) => ({
90
+ name: m.name,
91
+ agent: m.agent,
92
+ status: m.status,
93
+ executionStatus: m.execution_status,
94
+ worktreeBranch: m.worktree_branch,
95
+ prompt: m.prompt,
96
+ model: m.model,
97
+ planApproval: m.plan_approval,
98
+ timeCreated: m.time_created,
99
+ timeUpdated: m.time_updated,
100
+ }))
101
+ return {
84
102
  id: t.id,
85
103
  name: t.name,
104
+ projectId: t.project_id,
86
105
  status: t.status,
87
106
  leadAgent: t.lead_agent,
88
107
  timeCreated: t.time_created,
89
108
  timeUpdated: t.time_updated,
90
- members: (memberStmt.all(t.id) as MemberRow[]).map((m) => ({
91
- name: m.name,
92
- agent: m.agent,
93
- status: m.status,
94
- executionStatus: m.execution_status,
95
- worktreeBranch: m.worktree_branch,
96
- prompt: m.prompt,
97
- model: m.model,
98
- planApproval: m.plan_approval,
99
- timeCreated: m.time_created,
100
- timeUpdated: m.time_updated,
101
- })),
109
+ members,
102
110
  tasks: (taskStmt.all(t.id) as TaskRow[]).map((tk) => ({
103
111
  id: tk.id,
104
112
  content: tk.content,
@@ -118,7 +126,87 @@ function buildState(db: Database): { teams: unknown[] } {
118
126
  read: msg.read === 1,
119
127
  timeCreated: msg.time_created,
120
128
  })),
121
- })),
129
+ }
130
+ })
131
+
132
+ const teamsByProject = new Map<string, unknown[]>()
133
+ mappedTeams.forEach(team => {
134
+ const projectId = (team as { projectId: string }).projectId
135
+ teamsByProject.set(projectId, [...(teamsByProject.get(projectId) ?? []), team])
136
+ })
137
+
138
+ return {
139
+ projects: projects.flatMap(project => {
140
+ const projectTeams = teamsByProject.get(project.id) ?? []
141
+ if (projectTeams.length === 0) return []
142
+ return {
143
+ id: project.id,
144
+ name: project.name,
145
+ path: project.path,
146
+ status: project.status,
147
+ timeCreated: project.time_created,
148
+ timeUpdated: project.time_updated,
149
+ activeTeams: projectTeams.filter(team => (team as { status: string }).status === "active").length,
150
+ workingAgents: projectTeams.reduce<number>((count, team) => {
151
+ const members = (team as { members: Array<{ status: string }> }).members
152
+ return count + members.filter(member => member.status === "busy").length
153
+ }, 0),
154
+ teams: projectTeams,
155
+ }
156
+ }),
157
+ teams: mappedTeams,
158
+ }
159
+ }
160
+
161
+ /** Dashboard server handle returned by startDashboard. */
162
+ export interface DashboardServer {
163
+ stop(force?: boolean): void
164
+ }
165
+
166
+ function sendJson(res: ServerResponse, data: unknown): void {
167
+ res.writeHead(200, {
168
+ "Content-Type": "application/json",
169
+ "Access-Control-Allow-Origin": "*",
170
+ })
171
+ res.end(JSON.stringify(data))
172
+ }
173
+
174
+ function handleDashboardRequest(db: Database, port: number, req: IncomingMessage, res: ServerResponse): void {
175
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? `localhost:${port}`}`)
176
+
177
+ if (url.pathname === "/api/health") {
178
+ sendJson(res, { ensemble: true, pid: process.pid })
179
+ return
180
+ }
181
+
182
+ if (url.pathname === "/api/state") {
183
+ sendJson(res, buildState(db))
184
+ return
185
+ }
186
+
187
+ if (url.pathname === "/") {
188
+ res.writeHead(200, { "Content-Type": "text/html" })
189
+ res.end(DASHBOARD_HTML)
190
+ return
191
+ }
192
+
193
+ res.writeHead(404, { "Content-Type": "text/plain" })
194
+ res.end("Not Found")
195
+ }
196
+
197
+ function toDashboardServer(server: Server): DashboardServer {
198
+ return {
199
+ stop(force?: boolean) {
200
+ server.close()
201
+ // server.close() only stops accepting new connections — under Node's
202
+ // node:http, idle keep-alive sockets keep the listener busy until the
203
+ // keep-alive timeout. closeAllConnections() (Node ≥ 18.2) terminates
204
+ // them promptly, matching the behaviour Bun.serve().stop(true) had.
205
+ if (force) {
206
+ const closeAll = (server as unknown as { closeAllConnections?: () => void }).closeAllConnections
207
+ if (typeof closeAll === "function") closeAll.call(server)
208
+ }
209
+ },
122
210
  }
123
211
  }
124
212
 
@@ -128,54 +216,42 @@ function buildState(db: Database): { teams: unknown[] } {
128
216
  * Singleton: if the port is already in use by another ensemble instance, skips silently.
129
217
  * Returns the server instance, or null if skipped.
130
218
  */
131
- export async function startDashboard(db: Database, port: number): Promise<ReturnType<typeof Bun.serve> | null> {
132
- try {
133
- const server = Bun.serve({
134
- port,
135
- fetch(req) {
136
- const url = new URL(req.url)
137
-
138
- if (url.pathname === "/api/health") {
139
- return jsonResponse({ ensemble: true, pid: process.pid })
140
- }
141
-
142
- if (url.pathname === "/api/state") {
143
- return jsonResponse(buildState(db))
144
- }
145
-
146
- if (url.pathname === "/") {
147
- return new Response(DASHBOARD_HTML, {
148
- headers: { "Content-Type": "text/html" },
149
- })
150
- }
151
-
152
- return new Response("Not Found", { status: 404 })
153
- },
154
- })
155
- log(`dashboard:started port=${port} url=http://localhost:${port}`)
156
- return server
157
- } catch (err) {
158
- if (err && typeof err === "object" && "code" in err && err.code === "EADDRINUSE") {
159
- try {
160
- const res = await fetch(`http://localhost:${port}/api/health`)
161
- const data = await res.json() as { ensemble?: boolean; pid?: number }
162
- if (data.ensemble && data.pid) {
163
- // Check if the other process is still alive
164
- let alive = false
165
- try { process.kill(data.pid, 0); alive = true } catch { /* process is dead */ }
166
- if (alive && data.pid !== process.pid) {
167
- log(`dashboard:already-running port=${port} pid=${data.pid}`)
168
- return null
219
+ export async function startDashboard(db: Database, port: number): Promise<DashboardServer | null> {
220
+ return new Promise((resolve) => {
221
+ const server = createServer((req, res) => handleDashboardRequest(db, port, req, res))
222
+
223
+ server.once("error", async (err: NodeJS.ErrnoException) => {
224
+ if (err.code === "EADDRINUSE") {
225
+ try {
226
+ const res = await fetch(`http://localhost:${port}/api/health`)
227
+ const data = await res.json() as { ensemble?: boolean; pid?: number }
228
+ if (data.ensemble && data.pid) {
229
+ // Check if the other process is still alive
230
+ let alive = false
231
+ try { process.kill(data.pid, 0); alive = true } catch { /* process is dead */ }
232
+ if (alive && data.pid !== process.pid) {
233
+ log(`dashboard:already-running port=${port} pid=${data.pid}`)
234
+ resolve(null)
235
+ return
236
+ }
237
+ // Stale server from a dead process — warn the user
238
+ log(`dashboard:stale-server port=${port} stale-pid=${data.pid} — run: kill -9 ${data.pid} || lsof -ti:${port} | xargs kill -9`)
239
+ resolve(null)
240
+ return
169
241
  }
170
- // Stale server from a dead process — warn the user
171
- log(`dashboard:stale-server port=${port} stale-pid=${data.pid} — run: kill -9 ${data.pid} || lsof -ti:${port} | xargs kill -9`)
172
- return null
173
- }
174
- } catch { /* health check failed — port held by something else */ }
175
- log(`dashboard:port-in-use port=${port} (not an ensemble instance)`)
176
- return null
177
- }
178
- log(`dashboard:failed err=${err instanceof Error ? err.message : String(err)}`)
179
- return null
180
- }
242
+ } catch { /* health check failed — port held by something else */ }
243
+ log(`dashboard:port-in-use port=${port} (not an ensemble instance)`)
244
+ resolve(null)
245
+ return
246
+ }
247
+
248
+ log(`dashboard:failed err=${err.message}`)
249
+ resolve(null)
250
+ })
251
+
252
+ server.listen(port, () => {
253
+ log(`dashboard:started port=${port} url=http://localhost:${port}`)
254
+ resolve(toDashboardServer(server))
255
+ })
256
+ })
181
257
  }
package/src/db.ts CHANGED
@@ -1,13 +1,96 @@
1
- import { Database } from "bun:sqlite"
1
+ import { createRequire } from "node:module"
2
2
  import path from "node:path"
3
3
  import { applyMigrations } from "./schema"
4
4
 
5
+ interface SqliteStatement {
6
+ get(...params: unknown[]): unknown
7
+ all(...params: unknown[]): unknown[]
8
+ run(...params: unknown[]): unknown
9
+ }
10
+
11
+ interface SqliteRunResult {
12
+ changes: number
13
+ }
14
+
15
+ /** Minimal synchronous SQLite database interface used by Ensemble. */
16
+ export interface Database {
17
+ exec(sql: string): void
18
+ query(sql: string): SqliteStatement
19
+ run(sql: string, ...params: unknown[]): SqliteRunResult
20
+ transaction<TArgs extends unknown[], TResult>(fn: (...args: TArgs) => TResult): (...args: TArgs) => TResult
21
+ close(): void
22
+ }
23
+
24
+ interface BunSqliteModule {
25
+ Database: new (filename: string) => Database
26
+ }
27
+
28
+ interface NodeSqliteDatabase {
29
+ exec(sql: string): void
30
+ prepare(sql: string): SqliteStatement
31
+ close(): void
32
+ }
33
+
34
+ interface NodeSqliteModule {
35
+ DatabaseSync: new (filename: string) => NodeSqliteDatabase
36
+ }
37
+
38
+ const requireRuntimeModule = createRequire(import.meta.url)
39
+
5
40
  let instance: Database | undefined
6
41
 
42
+ class NodeSqliteAdapter implements Database {
43
+ private readonly db: NodeSqliteDatabase
44
+
45
+ constructor(filename: string) {
46
+ const sqlite = requireRuntimeModule("node:sqlite") as NodeSqliteModule
47
+ this.db = new sqlite.DatabaseSync(filename)
48
+ }
49
+
50
+ exec(sql: string): void {
51
+ this.db.exec(sql)
52
+ }
53
+
54
+ query(sql: string): SqliteStatement {
55
+ return this.db.prepare(sql)
56
+ }
57
+
58
+ run(sql: string, ...params: unknown[]): SqliteRunResult {
59
+ const bindings = params.length === 1 && Array.isArray(params[0]) ? params[0] : params
60
+ return this.db.prepare(sql).run(...bindings) as SqliteRunResult
61
+ }
62
+
63
+ transaction<TArgs extends unknown[], TResult>(fn: (...args: TArgs) => TResult): (...args: TArgs) => TResult {
64
+ return (...args: TArgs): TResult => {
65
+ this.exec("BEGIN")
66
+ try {
67
+ const result = fn(...args)
68
+ this.exec("COMMIT")
69
+ return result
70
+ } catch (err) {
71
+ this.exec("ROLLBACK")
72
+ throw err
73
+ }
74
+ }
75
+ }
76
+
77
+ close(): void {
78
+ this.db.close()
79
+ }
80
+ }
81
+
82
+ function openDatabase(filename: string): Database {
83
+ if (typeof process.versions.bun === "string") {
84
+ const sqlite = requireRuntimeModule(["bun", "sqlite"].join(":")) as BunSqliteModule
85
+ return new sqlite.Database(filename)
86
+ }
87
+
88
+ return new NodeSqliteAdapter(filename)
89
+ }
90
+
7
91
  /**
8
- * Resolve the path for the ensemble SQLite database.
9
- * Always uses the global ~/.config/opencode/ directory, never the project directory.
10
- * Accepts an env override for testability.
92
+ * Resolve the path for the global ensemble SQLite database.
93
+ * Project data is logically isolated by project_id inside this DB.
11
94
  */
12
95
  export function getDbPath(env: Record<string, string | undefined> = process.env): string {
13
96
  const home = env.HOME ?? env.USERPROFILE ?? "~"
@@ -19,7 +102,7 @@ export function getDbPath(env: Record<string, string | undefined> = process.env)
19
102
  * Applies all pending migrations and enables WAL mode.
20
103
  */
21
104
  export function createDb(path: string): Database {
22
- const db = new Database(path)
105
+ const db = openDatabase(path)
23
106
  db.exec("PRAGMA journal_mode=WAL")
24
107
  db.exec("PRAGMA foreign_keys=ON")
25
108
  applyMigrations(db)