@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/README.md +15 -7
- package/dist/index.js +2093 -1639
- package/package.json +8 -4
- package/src/dashboard-html.ts +11 -3
- package/src/{dashboard-js-part1.ts → dashboard-js-core.ts} +11 -3
- package/src/{dashboard-js-part3.ts → dashboard-js-events.ts} +25 -4
- package/src/{dashboard-js-part2.ts → dashboard-js-render.ts} +13 -8
- package/src/dashboard.ts +154 -78
- package/src/db.ts +88 -5
- package/src/hooks.ts +72 -5
- package/src/index.ts +31 -20
- package/src/messaging.ts +1 -1
- package/src/notify.ts +1 -1
- package/src/process.ts +55 -0
- package/src/recovery.ts +56 -31
- package/src/schema.ts +123 -3
- package/src/system-prompt.ts +1 -1
- package/src/tools/merge-helper.ts +63 -27
- package/src/tools/shared.ts +7 -10
- package/src/tools/team-cleanup.ts +38 -28
- package/src/tools/team-create.ts +17 -5
- package/src/tools/team-shutdown.ts +8 -7
- package/src/tools/team-spawn.ts +3 -1
- package/src/types.ts +51 -5
- package/src/util.ts +17 -0
- package/src/watchdog.ts +5 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hueyexe/opencode-ensemble",
|
|
3
|
-
"version": "0.
|
|
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
|
|
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.
|
|
45
|
-
"@opencode-ai/sdk": "^1.
|
|
48
|
+
"@opencode-ai/plugin": "^1.15.1",
|
|
49
|
+
"@opencode-ai/sdk": "^1.15.1"
|
|
46
50
|
}
|
|
47
51
|
}
|
package/src/dashboard-html.ts
CHANGED
|
@@ -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-
|
|
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-[
|
|
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">></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
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'):'';
|
|
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
|
|
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
|
|
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
|
|
2
|
+
export const DASHBOARD_JS_RENDER = `
|
|
3
3
|
function rSel(){
|
|
4
|
-
const el=document.getElementById('
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
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 {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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>" +
|
|
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
|
|
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
|
-
|
|
83
|
-
|
|
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
|
|
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<
|
|
132
|
-
|
|
133
|
-
const server =
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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 {
|
|
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
|
-
*
|
|
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 =
|
|
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)
|