@hueyexe/opencode-ensemble 0.14.2 → 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.2",
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",
@@ -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
@@ -2,23 +2,33 @@ import { createServer } from "node:http"
2
2
  import type { IncomingMessage, Server, ServerResponse } from "node:http"
3
3
  import type { Database } from "./db"
4
4
  import { DASHBOARD_HEAD } from "./dashboard-html"
5
- import { DASHBOARD_JS_PART1 } from "./dashboard-js-part1"
6
- import { DASHBOARD_JS_PART2 } from "./dashboard-js-part2"
7
- 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"
8
8
  import { log } from "./log"
9
9
 
10
10
  /** Assemble the full dashboard HTML from parts. */
11
- 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>"
12
12
 
13
13
  interface TeamRow {
14
14
  id: string
15
15
  name: string
16
+ project_id: string
16
17
  status: string
17
18
  lead_agent: string | null
18
19
  time_created: number
19
20
  time_updated: number
20
21
  }
21
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
+
22
32
  interface MemberRow {
23
33
  name: string
24
34
  agent: string
@@ -68,32 +78,35 @@ function parseDependsOn(value: string | null): string[] {
68
78
  return []
69
79
  }
70
80
 
71
- function buildState(db: Database): { teams: unknown[] } {
72
- 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[]
73
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 = ?")
74
85
  const taskStmt = db.query("SELECT id, content, status, priority, assignee, depends_on, time_created, time_updated FROM team_task WHERE team_id = ?")
75
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")
76
87
 
77
- return {
78
- 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 {
79
102
  id: t.id,
80
103
  name: t.name,
104
+ projectId: t.project_id,
81
105
  status: t.status,
82
106
  leadAgent: t.lead_agent,
83
107
  timeCreated: t.time_created,
84
108
  timeUpdated: t.time_updated,
85
- members: (memberStmt.all(t.id) as MemberRow[]).map((m) => ({
86
- name: m.name,
87
- agent: m.agent,
88
- status: m.status,
89
- executionStatus: m.execution_status,
90
- worktreeBranch: m.worktree_branch,
91
- prompt: m.prompt,
92
- model: m.model,
93
- planApproval: m.plan_approval,
94
- timeCreated: m.time_created,
95
- timeUpdated: m.time_updated,
96
- })),
109
+ members,
97
110
  tasks: (taskStmt.all(t.id) as TaskRow[]).map((tk) => ({
98
111
  id: tk.id,
99
112
  content: tk.content,
@@ -113,7 +126,35 @@ function buildState(db: Database): { teams: unknown[] } {
113
126
  read: msg.read === 1,
114
127
  timeCreated: msg.time_created,
115
128
  })),
116
- })),
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,
117
158
  }
118
159
  }
119
160
 
package/src/db.ts CHANGED
@@ -89,9 +89,8 @@ function openDatabase(filename: string): Database {
89
89
  }
90
90
 
91
91
  /**
92
- * Resolve the path for the ensemble SQLite database.
93
- * Always uses the global ~/.config/opencode/ directory, never the project directory.
94
- * 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.
95
94
  */
96
95
  export function getDbPath(env: Record<string, string | undefined> = process.env): string {
97
96
  const home = env.HOME ?? env.USERPROFILE ?? "~"
package/src/index.ts CHANGED
@@ -45,7 +45,7 @@ const DEFAULT_WATCHDOG_CHECK_MS = 60 * 1000 // 60 seconds
45
45
  * peer-to-peer communication, shared task management, and coordinated execution.
46
46
  */
47
47
  const plugin: Plugin = async (input) => {
48
- // Initialize SQLite database in the global OpenCode config directory
48
+ // Initialize SQLite database in the global OpenCode config directory.
49
49
  const dbPath = getDbPath()
50
50
  mkdirSync(path.dirname(dbPath), { recursive: true })
51
51
  const db = createDb(dbPath)
@@ -212,16 +212,14 @@ const plugin: Plugin = async (input) => {
212
212
  "SELECT worktree_branch, name, team_id FROM team_member WHERE session_id = ?"
213
213
  ).get(sessionID) as { worktree_branch: string | null; name: string; team_id: string } | null
214
214
  if (member?.worktree_branch && !member.worktree_branch.startsWith("ensemble/preserved/")) {
215
- const teamRow = deps.db.query("SELECT name FROM team WHERE id = ?").get(member.team_id) as { name: string } | null
216
- if (teamRow) {
217
- const { preserveBranch: preserve, preservedBranchName: branchName } = await import("./tools/merge-helper")
218
- const safeBranch = branchName(teamRow.name, member.name)
219
- const ok = await preserve(member.worktree_branch, safeBranch, deps.directory)
220
- if (ok) {
221
- deps.db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?",
222
- [safeBranch, member.team_id, member.name])
223
- log(`busy_while_shutdown:branch:preserved src=${member.worktree_branch} target=${safeBranch}`)
224
- }
215
+ const { getTeamResourceParts, preserveBranch: preserve, preservedBranchName: branchName } = await import("./tools/merge-helper")
216
+ const resource = getTeamResourceParts(deps.db, member.team_id)
217
+ const safeBranch = branchName(resource.projectName, resource.teamName, resource.teamId, member.name)
218
+ const ok = await preserve(member.worktree_branch, safeBranch, deps.directory)
219
+ if (ok) {
220
+ deps.db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?",
221
+ [safeBranch, member.team_id, member.name])
222
+ log(`busy_while_shutdown:branch:preserved src=${member.worktree_branch} target=${safeBranch}`)
225
223
  }
226
224
  }
227
225
  try {
@@ -372,6 +370,7 @@ const plugin: Plugin = async (input) => {
372
370
  description: "Create a new agent team. You become the team lead. Use this before spawning teammates.",
373
371
  args: {
374
372
  name: tool.schema.string().describe("Team name (lowercase alphanumeric with hyphens, 1-64 chars)"),
373
+ project_name: tool.schema.string().optional().describe("Project display name for first use of this working directory. If omitted, a short random name is generated."),
375
374
  },
376
375
  async execute(args, ctx) {
377
376
  const result = await executeTeamCreate(deps, args, ctx.sessionID)
package/src/recovery.ts CHANGED
@@ -2,7 +2,7 @@ import type { Database } from "./db"
2
2
  import type { PluginClient } from "./types"
3
3
  import type { MemberRegistry } from "./state"
4
4
  import { getUndeliveredMessages, markDelivered, hasReportedCompletion } from "./messaging"
5
- import { preserveBranch, preservedBranchName } from "./tools/merge-helper"
5
+ import { preserveBranch, preservedBranchName, teamResourceSegment } from "./tools/merge-helper"
6
6
  import { log } from "./log"
7
7
  import { runCommand } from "./process"
8
8
 
@@ -16,17 +16,19 @@ import { runCommand } from "./process"
16
16
  export async function recoverStaleMembers(db: Database, client?: PluginClient, cwd?: string): Promise<{ interrupted: number }> {
17
17
  // Find stale members with branch info so we can preserve before aborting
18
18
  const stale = db.query(
19
- `SELECT tm.session_id, tm.worktree_branch, tm.name, tm.team_id, t.name as team_name
20
- FROM team_member tm
21
- JOIN team t ON tm.team_id = t.id
22
- WHERE tm.status = 'busy' AND t.status = 'active'`
23
- ).all() as Array<{ session_id: string; worktree_branch: string | null; name: string; team_id: string; team_name: string }>
19
+ `SELECT tm.session_id, tm.worktree_branch, tm.name, tm.team_id, t.name as team_name, p.name as project_name
20
+ FROM team_member tm
21
+ JOIN team t ON tm.team_id = t.id
22
+ JOIN project p ON t.project_id = p.id
23
+ WHERE tm.status = 'busy' AND t.status = 'active'
24
+ AND (? IS NULL OR t.project_id = ? OR t.project_id = 'default')`
25
+ ).all(cwd ?? null, cwd ?? null) as Array<{ session_id: string; worktree_branch: string | null; name: string; team_id: string; team_name: string; project_name: string }>
24
26
 
25
27
  const result = db.run(
26
28
  `UPDATE team_member SET status = 'error', execution_status = 'idle', time_updated = ?
27
- WHERE status = 'busy'
28
- AND team_id IN (SELECT id FROM team WHERE status = 'active')`,
29
- [Date.now()]
29
+ WHERE status = 'busy'
30
+ AND team_id IN (SELECT id FROM team WHERE status = 'active' AND (? IS NULL OR project_id = ? OR project_id = 'default'))`,
31
+ [Date.now(), cwd ?? null, cwd ?? null]
30
32
  )
31
33
 
32
34
  // Preserve branches then abort orphaned sessions
@@ -34,7 +36,7 @@ export async function recoverStaleMembers(db: Database, client?: PluginClient, c
34
36
  for (const member of stale) {
35
37
  // Preserve branch BEFORE abort — session.abort() may destroy the worktree + branch
36
38
  if (cwd && member.worktree_branch && !member.worktree_branch.startsWith("ensemble/preserved/")) {
37
- const safeBranch = preservedBranchName(member.team_name, member.name)
39
+ const safeBranch = preservedBranchName(member.project_name, member.team_name, member.team_id, member.name)
38
40
  const ok = await preserveBranch(member.worktree_branch, safeBranch, cwd)
39
41
  if (ok) {
40
42
  db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?",
@@ -178,19 +180,25 @@ export function rehydrateRegistry(db: Database, registry: MemberRegistry): numbe
178
180
  export async function recoverOrphanedBranches(db: Database, cwd: string): Promise<{ removed: number }> {
179
181
  let removed = 0
180
182
 
181
- // Get archived team names that have NO active members
183
+ // Get archived team namespaces for this project that have NO active members.
184
+ // The team id namespace is current; team names are kept for legacy preserved branches.
182
185
  const archivedTeams = db.query(
183
- `SELECT t.name FROM team t
186
+ `SELECT t.id, t.name, p.name as project_name FROM team t
187
+ JOIN project p ON t.project_id = p.id
184
188
  WHERE t.status = 'archived'
189
+ AND t.project_id = ?
185
190
  AND NOT EXISTS (
186
- SELECT 1 FROM team_member tm
187
- WHERE tm.team_id = t.id AND tm.status NOT IN ('shutdown', 'error')
188
- )`
189
- ).all() as Array<{ name: string }>
191
+ SELECT 1 FROM team_member tm
192
+ WHERE tm.team_id = t.id AND tm.status NOT IN ('shutdown', 'error')
193
+ )`
194
+ ).all(cwd) as Array<{ id: string; name: string; project_name: string }>
190
195
 
191
196
  if (archivedTeams.length === 0) return { removed: 0 }
192
197
 
193
- const archivedNames = new Set(archivedTeams.map(t => t.name))
198
+ const archivedPrefixes = archivedTeams.flatMap(t => [
199
+ `ensemble/preserved/${t.project_name}/${teamResourceSegment(t.name, t.id)}/`,
200
+ `ensemble/preserved/${t.name}/`,
201
+ ])
194
202
 
195
203
  // List all local branches matching ensemble/preserved/*
196
204
  const result = await runCommand(["git", "branch", "--list", "ensemble/preserved/*"], { cwd })
@@ -198,11 +206,7 @@ export async function recoverOrphanedBranches(db: Database, cwd: string): Promis
198
206
  const branches = result.stdout.split("\n").map(b => b.trim().replace(/^\* /, "")).filter(Boolean)
199
207
 
200
208
  for (const branch of branches) {
201
- // Parse team name from branch: ensemble/preserved/{teamName}/{memberName}
202
- const parts = branch.split("/")
203
- if (parts.length < 4) continue
204
- const teamName = parts[2]
205
- if (!teamName || !archivedNames.has(teamName)) continue
209
+ if (!archivedPrefixes.some(prefix => branch.startsWith(prefix))) continue
206
210
 
207
211
  try {
208
212
  const deleteResult = await runCommand(["git", "branch", "-D", branch], { cwd })
package/src/schema.ts CHANGED
@@ -82,6 +82,110 @@ export const MIGRATIONS: string[] = [
82
82
  `ALTER TABLE team_member ADD COLUMN workspace_id TEXT;`,
83
83
  // Migration 7: Track whether teammate has reported to lead (completion loop prevention, issue #3)
84
84
  `ALTER TABLE team_member ADD COLUMN reported_to_lead INTEGER NOT NULL DEFAULT 0;`,
85
+ // Migration 8: Add project as the dashboard grouping level. For this initial
86
+ // project-first step, project_id is the team lead's working directory.
87
+ `PRAGMA foreign_keys=OFF;
88
+ ALTER TABLE team RENAME TO team_old_m8;
89
+ CREATE TABLE IF NOT EXISTS project (
90
+ id TEXT PRIMARY KEY,
91
+ name TEXT NOT NULL,
92
+ path TEXT NOT NULL,
93
+ status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'archived')),
94
+ time_created INTEGER NOT NULL,
95
+ time_updated INTEGER NOT NULL
96
+ );
97
+ INSERT OR IGNORE INTO project (id, name, path, status, time_created, time_updated)
98
+ VALUES ('default', 'Default Project', '', 'active', strftime('%s','now') * 1000, strftime('%s','now') * 1000);
99
+ CREATE TABLE team (
100
+ id TEXT PRIMARY KEY,
101
+ name TEXT NOT NULL,
102
+ project_id TEXT NOT NULL DEFAULT 'default' REFERENCES project(id) ON DELETE CASCADE,
103
+ lead_session_id TEXT NOT NULL,
104
+ status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'archived')),
105
+ delegate INTEGER NOT NULL DEFAULT 0,
106
+ time_created INTEGER NOT NULL,
107
+ time_updated INTEGER NOT NULL,
108
+ lead_agent TEXT
109
+ );
110
+ INSERT INTO team (id, name, project_id, lead_session_id, status, delegate, time_created, time_updated, lead_agent)
111
+ SELECT id, name, 'default', lead_session_id, status, delegate, time_created, time_updated, lead_agent FROM team_old_m8;
112
+ DROP TABLE team_old_m8;
113
+
114
+ ALTER TABLE team_member RENAME TO team_member_old_m8;
115
+ CREATE TABLE team_member (
116
+ team_id TEXT NOT NULL REFERENCES team(id) ON DELETE CASCADE,
117
+ name TEXT NOT NULL,
118
+ session_id TEXT NOT NULL,
119
+ agent TEXT NOT NULL,
120
+ status TEXT NOT NULL DEFAULT 'ready'
121
+ CHECK(status IN ('ready', 'busy', 'shutdown_requested', 'shutdown', 'error')),
122
+ execution_status TEXT NOT NULL DEFAULT 'idle'
123
+ CHECK(execution_status IN ('idle', 'starting', 'running',
124
+ 'cancel_requested', 'cancelling', 'cancelled',
125
+ 'completing', 'completed', 'failed', 'timed_out')),
126
+ model TEXT,
127
+ prompt TEXT,
128
+ time_created INTEGER NOT NULL,
129
+ time_updated INTEGER NOT NULL,
130
+ worktree_dir TEXT,
131
+ worktree_branch TEXT,
132
+ plan_approval TEXT NOT NULL DEFAULT 'none'
133
+ CHECK(plan_approval IN ('none', 'pending', 'approved', 'rejected')),
134
+ workspace_id TEXT,
135
+ reported_to_lead INTEGER NOT NULL DEFAULT 0,
136
+ PRIMARY KEY (team_id, name)
137
+ );
138
+ INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, time_created, time_updated, worktree_dir, worktree_branch, plan_approval, workspace_id, reported_to_lead)
139
+ SELECT team_id, name, session_id, agent, status, execution_status, model, prompt, time_created, time_updated, worktree_dir, worktree_branch, plan_approval, workspace_id, reported_to_lead FROM team_member_old_m8;
140
+ DROP TABLE team_member_old_m8;
141
+
142
+ ALTER TABLE team_task RENAME TO team_task_old_m8;
143
+ CREATE TABLE team_task (
144
+ id TEXT PRIMARY KEY,
145
+ team_id TEXT NOT NULL REFERENCES team(id) ON DELETE CASCADE,
146
+ content TEXT NOT NULL,
147
+ status TEXT NOT NULL DEFAULT 'pending'
148
+ CHECK(status IN ('pending', 'in_progress', 'completed', 'cancelled', 'blocked')),
149
+ priority TEXT NOT NULL DEFAULT 'medium'
150
+ CHECK(priority IN ('high', 'medium', 'low')),
151
+ assignee TEXT,
152
+ depends_on TEXT,
153
+ time_created INTEGER NOT NULL,
154
+ time_updated INTEGER NOT NULL
155
+ );
156
+ INSERT INTO team_task (id, team_id, content, status, priority, assignee, depends_on, time_created, time_updated)
157
+ SELECT id, team_id, content, status, priority, assignee, depends_on, time_created, time_updated FROM team_task_old_m8;
158
+ DROP TABLE team_task_old_m8;
159
+
160
+ ALTER TABLE team_message RENAME TO team_message_old_m8;
161
+ CREATE TABLE team_message (
162
+ id TEXT PRIMARY KEY,
163
+ team_id TEXT NOT NULL REFERENCES team(id) ON DELETE CASCADE,
164
+ from_name TEXT NOT NULL,
165
+ to_name TEXT,
166
+ content TEXT NOT NULL,
167
+ delivered INTEGER NOT NULL DEFAULT 0,
168
+ time_created INTEGER NOT NULL,
169
+ read INTEGER NOT NULL DEFAULT 0
170
+ );
171
+ INSERT INTO team_message (id, team_id, from_name, to_name, content, delivered, time_created, read)
172
+ SELECT id, team_id, from_name, to_name, content, delivered, time_created, read FROM team_message_old_m8;
173
+ DROP TABLE team_message_old_m8;
174
+
175
+ CREATE INDEX IF NOT EXISTS team_lead_idx ON team(lead_session_id);
176
+ CREATE INDEX IF NOT EXISTS team_status_idx ON team(status);
177
+ CREATE INDEX IF NOT EXISTS team_project_idx ON team(project_id, status);
178
+ CREATE UNIQUE INDEX IF NOT EXISTS team_active_project_name_idx ON team(project_id, name) WHERE status = 'active';
179
+ CREATE INDEX IF NOT EXISTS team_member_session_idx ON team_member(session_id);
180
+ CREATE INDEX IF NOT EXISTS team_member_status_idx ON team_member(team_id, status);
181
+ CREATE INDEX IF NOT EXISTS team_task_team_idx ON team_task(team_id);
182
+ CREATE INDEX IF NOT EXISTS team_task_assignee_idx ON team_task(assignee);
183
+ CREATE INDEX IF NOT EXISTS team_task_status_idx ON team_task(team_id, status);
184
+ CREATE INDEX IF NOT EXISTS team_message_team_idx ON team_message(team_id);
185
+ CREATE INDEX IF NOT EXISTS team_message_to_idx ON team_message(to_name);
186
+ CREATE INDEX IF NOT EXISTS team_message_undelivered_idx ON team_message(team_id, delivered) WHERE delivered = 0;
187
+ CREATE INDEX IF NOT EXISTS team_message_unread_idx ON team_message(team_id, read) WHERE read = 0;
188
+ PRAGMA foreign_keys=ON;`,
85
189
  ]
86
190
 
87
191
  /**
@@ -91,9 +195,25 @@ export const MIGRATIONS: string[] = [
91
195
  export function applyMigrations(db: Database): void {
92
196
  const { user_version: current } = db.query("PRAGMA user_version").get() as { user_version: number }
93
197
 
198
+ if (current > MIGRATIONS.length) {
199
+ throw new Error(`Database schema version ${current} is newer than this plugin supports (${MIGRATIONS.length}). Upgrade opencode-ensemble before continuing.`)
200
+ }
201
+
94
202
  for (let i = current; i < MIGRATIONS.length; i++) {
95
203
  const migration = MIGRATIONS[i]
96
- if (migration) db.exec(migration)
97
- db.exec(`PRAGMA user_version = ${i + 1}`)
204
+ const { foreign_keys: foreignKeys } = db.query("PRAGMA foreign_keys").get() as { foreign_keys: number }
205
+
206
+ db.exec("PRAGMA foreign_keys = OFF")
207
+ db.exec("BEGIN IMMEDIATE")
208
+ try {
209
+ if (migration) db.exec(migration)
210
+ db.exec(`PRAGMA user_version = ${i + 1}`)
211
+ db.exec("COMMIT")
212
+ } catch (err) {
213
+ db.exec("ROLLBACK")
214
+ throw err
215
+ } finally {
216
+ db.exec(`PRAGMA foreign_keys = ${foreignKeys ? "ON" : "OFF"}`)
217
+ }
98
218
  }
99
219
  }