@hueyexe/opencode-ensemble 0.18.0 → 0.21.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.18.0",
3
+ "version": "0.21.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",
@@ -48,7 +48,7 @@
48
48
  "@opentui/core": "0.5.10",
49
49
  "@opentui/solid": "0.5.10",
50
50
  "@types/bun": "latest",
51
- "solid-js": "1.9.0"
51
+ "solid-js": "1.9.4"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "typescript": "^5",
package/src/config.ts CHANGED
@@ -17,6 +17,8 @@ export interface EnsembleConfig {
17
17
  rateLimitCapacity?: number
18
18
  /** Dashboard server port (default: 4747, 0 to disable) */
19
19
  dashboardPort?: number
20
+ /** Dashboard bind address (default: 127.0.0.1) */
21
+ dashboardHost?: string
20
22
  /** Max peer messages per agent per window before nudge (default: 5, 0 to disable) */
21
23
  peerMessageLimit?: number
22
24
  /** Time window for peer message rate limiting in ms (default: 300000 = 5 min) */
@@ -31,6 +33,18 @@ export interface EnsembleConfig {
31
33
  modelAssignment?: "default" | "rotate" | "random"
32
34
  /** Lead asks user about model preferences before spawning (default: false) */
33
35
  promptForModels?: boolean
36
+ /** Max task dependency depth (default: 0 = unlimited). Roots count as depth 1. */
37
+ taskDepthLimit?: number
38
+ /** What to do when a task would exceed taskDepthLimit (default: "lead_resolve"). */
39
+ taskDepthLimitOverflowAction?: "lead_resolve" | "user_resolve"
40
+ /**
41
+ * How much live team state to inject into the system prompt:
42
+ * "full" (default) includes statuses, tasks, and message payloads;
43
+ * "state-only" drops message payloads (use team_results);
44
+ * "off" injects only stable role/workflow guidance.
45
+ * Lower settings avoid invalidating provider prefix caches on every change.
46
+ */
47
+ systemPromptTeamState?: "full" | "state-only" | "off"
34
48
  }
35
49
 
36
50
  /** Default configuration values. */
@@ -42,6 +56,7 @@ export const DEFAULT_CONFIG: Required<EnsembleConfig> = {
42
56
  timeoutMs: 30 * 60 * 1000,
43
57
  rateLimitCapacity: 10,
44
58
  dashboardPort: 4747,
59
+ dashboardHost: "127.0.0.1",
45
60
  peerMessageLimit: 5,
46
61
  peerMessageWindowMs: 300_000,
47
62
  defaultModel: "",
@@ -49,6 +64,9 @@ export const DEFAULT_CONFIG: Required<EnsembleConfig> = {
49
64
  modelsByAgent: {},
50
65
  modelAssignment: "default",
51
66
  promptForModels: false,
67
+ systemPromptTeamState: "full",
68
+ taskDepthLimit: 0,
69
+ taskDepthLimitOverflowAction: "lead_resolve",
52
70
  }
53
71
 
54
72
  /** Read a JSON config file, returning an empty object on missing/invalid. */
@@ -65,6 +83,7 @@ function readConfigFile(filePath: string): Partial<EnsembleConfig> {
65
83
  if (typeof raw.timeoutMs === "number") result.timeoutMs = raw.timeoutMs
66
84
  if (typeof raw.rateLimitCapacity === "number") result.rateLimitCapacity = raw.rateLimitCapacity
67
85
  if (typeof raw.dashboardPort === "number") result.dashboardPort = raw.dashboardPort
86
+ if (typeof raw.dashboardHost === "string") result.dashboardHost = raw.dashboardHost
68
87
  if (typeof raw.peerMessageLimit === "number") result.peerMessageLimit = raw.peerMessageLimit
69
88
  if (typeof raw.peerMessageWindowMs === "number") result.peerMessageWindowMs = raw.peerMessageWindowMs
70
89
  if (typeof raw.defaultModel === "string") result.defaultModel = raw.defaultModel
@@ -75,6 +94,20 @@ function readConfigFile(filePath: string): Partial<EnsembleConfig> {
75
94
  }
76
95
  if (typeof raw.modelAssignment === "string" && ["default", "rotate", "random"].includes(raw.modelAssignment)) result.modelAssignment = raw.modelAssignment as "default" | "rotate" | "random"
77
96
  if (typeof raw.promptForModels === "boolean") result.promptForModels = raw.promptForModels
97
+ if (typeof raw.systemPromptTeamState === "string" && ["full", "state-only", "off"].includes(raw.systemPromptTeamState)) {
98
+ result.systemPromptTeamState = raw.systemPromptTeamState as "full" | "state-only" | "off"
99
+ }
100
+ // Accept the issue #42 snake_case spellings as aliases; camelCase wins.
101
+ const depthLimit = typeof raw.taskDepthLimit === "number" ? raw.taskDepthLimit : raw.depth_limit
102
+ if (typeof depthLimit === "number" && Number.isInteger(depthLimit) && depthLimit >= 0) {
103
+ result.taskDepthLimit = depthLimit
104
+ }
105
+ const overflow = typeof raw.taskDepthLimitOverflowAction === "string"
106
+ ? raw.taskDepthLimitOverflowAction
107
+ : raw.depth_limit_overflow_action
108
+ if (overflow === "lead_resolve" || overflow === "user_resolve") {
109
+ result.taskDepthLimitOverflowAction = overflow
110
+ }
78
111
  return result
79
112
  } catch (err) {
80
113
  if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") return {}
@@ -32,6 +32,27 @@ select{-webkit-appearance:none;appearance:none;background-image:url("data:image/
32
32
  #content.nav-collapsed{grid-template-columns:2rem minmax(0,1fr)}
33
33
  #projects[hidden]{display:none!important}
34
34
  #project-rail[hidden]{display:none!important}
35
+ .dashboard-panel[hidden]{display:none!important}
36
+ .dashboard-tabs{display:flex;gap:24px;border-bottom:1px solid #2a3144;margin-bottom:16px}
37
+ .dashboard-tab{background:none;border:0;border-bottom:2px solid transparent;padding:8px 0 10px;color:#8a96aa;font-size:12px}
38
+ .dashboard-tab[aria-selected="true"]{color:#e2e8f0;border-bottom-color:#60a5fa}
39
+ .graph-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px;flex-wrap:wrap}
40
+ .graph-scroll{overflow:auto;max-height:65vh;border:1px solid #1e2433;border-radius:8px;background:#0f131c}
41
+ .graph-canvas{position:relative;background-image:radial-gradient(#2a314480 1px,transparent 1px);background-size:22px 22px}
42
+ .graph-edges{position:absolute;inset:0;pointer-events:none}
43
+ .graph-card{position:absolute;width:280px;height:172px;background:#141822;border:1px solid #2a3144;border-radius:8px;padding:12px;text-align:left;color:#e2e8f0;font-size:12px;cursor:pointer}
44
+ .graph-card:hover{border-color:#60a5fa}.graph-card[aria-pressed="true"]{outline:2px solid #60a5fa;outline-offset:1px}
45
+ .graph-card.graph-running{border-color:#3b82f680}.graph-card.graph-done{border-color:#22c55e60}.graph-card.graph-blocked{border-color:#f59e0b60}
46
+ .graph-card-head{display:flex;align-items:center;gap:6px;min-width:0}.graph-card-name{font-family:monospace;font-weight:600;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
47
+ .graph-card-task{font-size:13px;line-height:18px;height:54px;margin:8px 0 4px;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
48
+ .graph-card-meta{color:#8a96aa;font-size:10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:3px}
49
+ .graph-card-foot{display:flex;align-items:center;gap:6px;margin-top:5px;white-space:nowrap}
50
+ .graph-warning{border:1px solid #f59e0b60;background:#f59e0b0d;color:#fbbf24;padding:10px 12px;margin-bottom:10px;border-radius:6px;font-size:12px;overflow-wrap:anywhere}
51
+ .graph-detail{border:1px solid #1e2433;background:#141822;border-radius:8px;padding:14px;margin-top:12px;font-size:12px;overflow-wrap:anywhere}
52
+ .graph-detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;margin-top:12px}
53
+ .graph-detail-link{display:block;text-align:left;color:#c1c9d9;margin-top:6px;text-decoration:underline;text-underline-offset:3px}
54
+ .graph-legend{display:flex;flex-wrap:wrap;gap:14px;margin-top:10px;color:#8a96aa;font-size:11px}
55
+ @media(max-width:640px){.graph-detail-grid{grid-template-columns:1fr}.graph-scroll{max-height:55vh}}
35
56
  .xp{max-height:0;overflow:hidden;transition:max-height .3s ease-out}.xp-open{max-height:3000px;transition:max-height .5s ease-in}
36
57
  .card-sel{outline:2px solid rgba(59,130,246,.4);outline-offset:1px}
37
58
  .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}
@@ -76,13 +97,25 @@ select{-webkit-appearance:none;appearance:none;background-image:url("data:image/
76
97
  <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
98
  <div class="min-w-0">
78
99
  <section id="attention" aria-label="Team attention" class="mb-3"></section>
79
- <div class="grid grid-cols-1 xl:grid-cols-[minmax(360px,1.35fr)_minmax(320px,.8fr)] gap-4 items-start">
100
+ <div role="tablist" aria-label="Dashboard views" class="dashboard-tabs">
101
+ <button id="tab-overview" type="button" role="tab" aria-selected="true" aria-controls="panel-overview" class="dashboard-tab" data-dashboard-tab="overview">Overview</button>
102
+ <button id="tab-dependencies" type="button" role="tab" aria-selected="false" aria-controls="panel-dependencies" tabindex="-1" class="dashboard-tab" data-dashboard-tab="dependencies">Dependency graph</button>
103
+ </div>
104
+ <div id="panel-overview" role="tabpanel" aria-labelledby="tab-overview" class="dashboard-panel grid grid-cols-1 xl:grid-cols-[minmax(360px,1.35fr)_minmax(320px,.8fr)] gap-4 items-start">
80
105
  <section aria-label="Agent roster"><div id="agents" class="grid gap-2" style="grid-template-columns:repeat(auto-fill,minmax(300px,1fr))"></div></section>
81
106
  <div class="grid grid-cols-1 gap-4">
82
107
  <section aria-label="Task board"><div id="tasks" class="bg-base-900 rounded-lg p-3 border border-base-800/50"></div></section>
83
108
  <section aria-label="Activity feed"><div id="activity" class="bg-base-900 rounded-lg p-3 border border-base-800/50"></div></section>
84
109
  </div>
85
110
  </div>
111
+ <section id="panel-dependencies" role="tabpanel" aria-labelledby="tab-dependencies" class="dashboard-panel" hidden>
112
+ <div class="graph-toolbar"><div><h2 class="text-[14px] font-semibold">Task dependencies</h2><p id="graph-description" class="text-[11px] text-txt-400 mt-1">Prerequisite → dependent · Select a task to inspect its agent and dependencies</p></div><button type="button" id="graph-reset" class="text-[11px] text-txt-300 border border-base-700 rounded px-2 py-1">Reset view</button></div>
113
+ <div id="graph-content">
114
+ <div id="graph-scroll" class="graph-scroll scroll" tabindex="0" aria-label="Task dependency graph" aria-describedby="graph-description"></div>
115
+ <div class="graph-legend"><span class="text-emerald-400">✓ Completed prerequisite</span><span class="text-blue-400">● In progress</span><span class="text-amber-400">○ Blocked</span><span>→ Dependency direction</span></div>
116
+ <div id="graph-detail" class="graph-detail" aria-live="polite"></div>
117
+ </div>
118
+ </section>
86
119
  </div>
87
120
  </div>
88
121
  </main>
@@ -47,6 +47,7 @@ function render(){
47
47
  applyNavCollapse();
48
48
  const p=curProject();document.getElementById('crumb').textContent=p?' / '+projectLabel(p)+' / '+t.name:'';
49
49
  rHealth(t);rSum(t);rAttention(t);rAgents(t);rTasks(t);rActivity(t);rTimeline(t);
50
+ rGraph(t);
50
51
  }
51
52
 
52
53
  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()}
@@ -99,6 +100,12 @@ setInterval(function(){var t=cur();if(t)rClock(t);if(fails<3)conn(true)},1000);
99
100
  setInterval(poll,2500);
100
101
 
101
102
  document.addEventListener('click',function(e){
103
+ const button=e.target.closest?.('button');
104
+ if(button?.dataset.dashboardTab){selectDashboardTab(button.dataset.dashboardTab);return}
105
+ if(button?.id==='graph-reset'){resetGraphView();return}
106
+ if(button?.dataset.graphTask!==undefined){selectGraphTask(button.dataset.graphTask);return}
107
+ if(button?.dataset.graphSelect!==undefined){selectGraphTask(button.dataset.graphSelect);return}
108
+ if(button?.dataset.graphAgent!==undefined){openDrawer(button.dataset.graphAgent);return}
102
109
  const id=e.target&&e.target.id;
103
110
  if(id!=='nav-toggle'&&id!=='nav-expand')return;
104
111
  navCollapsed=id==='nav-toggle';
@@ -115,6 +122,8 @@ document.addEventListener('keydown',function(e){
115
122
  if(drawerOpen&&e.key==='Escape'){e.preventDefault();closeDrawer();return}
116
123
  if(drawerOpen&&e.key==='?'){e.preventDefault();return}
117
124
  if(e.target.tagName==='INPUT'||e.target.tagName==='SELECT'||e.target.tagName==='TEXTAREA')return;
125
+ if(e.target.closest?.('[role="tablist"]')){graphTabKeydown(e);return}
126
+ if(e.target.closest?.('#panel-dependencies')&&e.key!=='Escape'&&e.key!=='?')return;
118
127
  var t=cur();if(!t)return;
119
128
  var mm=[...(t.members||[])].sort((a,b)=>rankAgent(a,b,t));
120
129
  if(e.key==='?'){e.preventDefault();shortcutsOpen?closeShortcuts():openShortcuts();return}
@@ -0,0 +1,40 @@
1
+ /** Dashboard JS — deterministic task dependency layout, shared with graph rendering. */
2
+ export const DASHBOARD_JS_GRAPH_LAYOUT = `
3
+ const GRAPH_CARD_WIDTH=280,GRAPH_CARD_HEIGHT=172,GRAPH_GAP_X=88,GRAPH_GAP_Y=32,GRAPH_PADDING=24;
4
+ function graphDependencies(task){return [...new Set(Array.isArray(task.dependsOn)?task.dependsOn:[])]}
5
+ function graphLayout(tasks){
6
+ const byId=new Map(tasks.map(task=>[task.id,task])),edges=[],missing=[];
7
+ const degree=new Map(),children=new Map(),depth=new Map();
8
+ tasks.forEach(task=>{degree.set(task.id,0);children.set(task.id,[]);depth.set(task.id,0)});
9
+ tasks.forEach(task=>{
10
+ graphDependencies(task).forEach(id=>{
11
+ if(!byId.has(id)){missing.push({taskId:task.id,dependencyId:id});return}
12
+ edges.push({from:id,to:task.id});
13
+ degree.set(task.id,degree.get(task.id)+1);
14
+ children.get(id).push(task.id);
15
+ });
16
+ });
17
+ const queue=tasks.filter(task=>degree.get(task.id)===0).map(task=>task.id);
18
+ let head=0;
19
+ while(head<queue.length){
20
+ const id=queue[head++];
21
+ children.get(id).forEach(child=>{
22
+ depth.set(child,Math.max(depth.get(child),depth.get(id)+1));
23
+ degree.set(child,degree.get(child)-1);
24
+ if(degree.get(child)===0)queue.push(child);
25
+ });
26
+ }
27
+ const cyclic=tasks.filter(task=>degree.get(task.id)>0).map(task=>task.id);
28
+ const fallback=queue.reduce((max,id)=>Math.max(max,depth.get(id)+1),0);
29
+ cyclic.forEach(id=>depth.set(id,fallback));
30
+ const rows=new Map();
31
+ const nodes=tasks.map(task=>{
32
+ const column=depth.get(task.id),row=rows.get(column)||0;
33
+ rows.set(column,row+1);
34
+ return {task,x:GRAPH_PADDING+column*(GRAPH_CARD_WIDTH+GRAPH_GAP_X),y:GRAPH_PADDING+row*(GRAPH_CARD_HEIGHT+GRAPH_GAP_Y)};
35
+ });
36
+ const width=nodes.reduce((max,node)=>Math.max(max,node.x+GRAPH_CARD_WIDTH+GRAPH_PADDING),GRAPH_CARD_WIDTH+2*GRAPH_PADDING);
37
+ const height=nodes.reduce((max,node)=>Math.max(max,node.y+GRAPH_CARD_HEIGHT+GRAPH_PADDING),GRAPH_CARD_HEIGHT+2*GRAPH_PADDING);
38
+ return {nodes,edges,missing,cyclic,width,height};
39
+ }
40
+ `;
@@ -0,0 +1,99 @@
1
+ /** Dashboard JS — dependency graph view, task inspection, and tab interactions. */
2
+ export const DASHBOARD_JS_GRAPH = `
3
+ let dashboardTab='overview',graphSelectedId=null,graphTeamId=null,graphRenderedMarkup=null;
4
+ function selectDashboardTab(view){
5
+ dashboardTab=view==='dependencies'?'dependencies':'overview';
6
+ ['overview','dependencies'].forEach(name=>{
7
+ const active=dashboardTab===name,tab=document.getElementById('tab-'+name);
8
+ tab.setAttribute('aria-selected',String(active));tab.tabIndex=active?0:-1;
9
+ document.getElementById('panel-'+name).hidden=!active;
10
+ });
11
+ const team=cur();if(team)rGraph(team);
12
+ }
13
+ function graphTabKeydown(e){
14
+ if(!['ArrowLeft','ArrowRight','Home','End'].includes(e.key))return;
15
+ e.preventDefault();
16
+ const view=e.key==='Home'?'overview':e.key==='End'?'dependencies':e.target.id==='tab-overview'?'dependencies':'overview';
17
+ selectDashboardTab(view);document.getElementById('tab-'+view).focus();
18
+ }
19
+ function rGraph(team){
20
+ const switched=graphTeamId!==team.id;
21
+ if(switched){graphTeamId=team.id;graphSelectedId=null;graphRenderedMarkup=null;resetGraphView()}
22
+ if(graphSelectedId!==null&&!(team.tasks||[]).some(task=>task.id===graphSelectedId))graphSelectedId=null;
23
+ if(dashboardTab!=='dependencies')return;
24
+ const root=document.getElementById('graph-content'),scroll=document.getElementById('graph-scroll');
25
+ const left=scroll.scrollLeft,top=scroll.scrollTop,focused=document.activeElement;
26
+ const focusData=root.contains(focused)?{task:focused.dataset?.graphTask,select:focused.dataset?.graphSelect,agent:focused.dataset?.graphAgent,scroll:focused.id==='graph-scroll'}:null;
27
+ const html=graphMarkup(team,graphLayout(team.tasks||[]),graphSelectedId);
28
+ // SVG serialization expands self-closing paths; compare generated markup, not DOM HTML.
29
+ if(graphRenderedMarkup===html)return;
30
+ patch(root,html);graphRenderedMarkup=html;
31
+ if(focusData){
32
+ const target=focusData.scroll?document.getElementById('graph-scroll'):[...root.querySelectorAll('button')].find(button=>
33
+ (focusData.task!==undefined&&button.dataset.graphTask===focusData.task)||
34
+ (focusData.select!==undefined&&button.dataset.graphSelect===focusData.select)||
35
+ (focusData.agent!==undefined&&button.dataset.graphAgent===focusData.agent));
36
+ if(target)target.focus({preventScroll:true});else document.getElementById('graph-scroll').focus({preventScroll:true});
37
+ }
38
+ const nextScroll=document.getElementById('graph-scroll');nextScroll.scrollLeft=left;nextScroll.scrollTop=top;
39
+ }
40
+ function selectGraphTask(id){const team=cur();if(!team||!(team.tasks||[]).some(task=>task.id===id))return;rGraph(team);graphSelectedId=id;rGraph(team)}
41
+ function resetGraphView(){const scroll=document.getElementById('graph-scroll');if(scroll){scroll.scrollLeft=0;scroll.scrollTop=0}}
42
+ function graphTaskInfo(task,byId){
43
+ const deps=graphDependencies(task),waiting=deps.filter(id=>!['completed','cancelled'].includes(byId.get(id)?.status));
44
+ const terminal=task.status==='completed'||task.status==='cancelled',blocked=!terminal&&(task.status==='blocked'||waiting.length>0);
45
+ const color=task.status==='completed'?'green':blocked?'amber':task.status==='in_progress'?'blue':'muted';
46
+ return {deps,waiting,blocked,color,hint:terminal?'':waiting.length?'Blocked by '+waiting.length+' prerequisite'+(waiting.length===1?'':'s'):deps.length?'Prerequisites satisfied':'No prerequisites'};
47
+ }
48
+ function graphCard(node,team,byId,selectedId,cyclic){
49
+ const task=node.task,member=(team.members||[]).find(m=>m.name===task.assignee),info=graphTaskInfo(task,byId);
50
+ const state=member?si(member.status):null;
51
+ const tone=task.status==='completed'?'graph-done':info.blocked?'graph-blocked':task.status==='in_progress'?'graph-running':'';
52
+ return '<button type="button" class="graph-card '+tone+'" style="left:'+node.x+'px;top:'+node.y+'px" data-graph-task="'+E(task.id)+'" aria-pressed="'+String(task.id===selectedId)+'" title="'+E(task.content)+'">'+
53
+ '<div class="graph-card-head"><span class="graph-card-name">'+E(task.assignee||'Unassigned')+'</span>'+(state?chip('agent '+state.l,member.status==='busy'?'blue':member.status==='error'?'red':'muted'):'')+'</div>'+
54
+ '<div class="graph-card-task">'+E(task.content)+'</div>'+
55
+ '<div class="graph-card-meta">'+E(member?[member.agent,member.model].filter(Boolean).join(' · '):task.assignee?'Agent not in roster':'No agent assigned')+'</div>'+
56
+ '<div class="graph-card-foot">'+chip(E(String(task.status).replace(/_/g,' ')),info.color)+chip(E(task.priority||'medium'),'muted')+(cyclic?chip('cycle affected','amber'):'')+'</div>'+
57
+ '<div class="graph-card-meta" title="'+E(task.id)+'">'+E(info.hint||task.id)+'</div></button>';
58
+ }
59
+ function graphDetail(team,byId,selectedId){
60
+ const task=byId.get(selectedId);
61
+ if(!task)return '<div id="graph-detail" class="graph-detail" aria-live="polite"><h3 class="text-txt-200 font-semibold">Inspect a task</h3><p class="text-txt-400 mt-1">Select a card to see prerequisites, dependents, and its assigned agent.</p></div>';
62
+ const member=(team.members||[]).find(m=>m.name===task.assignee),info=graphTaskInfo(task,byId);
63
+ function links(ids){return ids.length?ids.map(id=>{
64
+ const linked=byId.get(id);
65
+ return linked?'<button type="button" class="graph-detail-link" data-graph-select="'+E(id)+'">'+E(linked.content)+' '+chip(E(String(linked.status).replace(/_/g,' ')),'muted')+'</button>':'<div class="text-amber-400 mt-1">Missing prerequisite: '+E(id)+'</div>';
66
+ }).join(''):'<div class="text-txt-500 mt-1">None</div>'}
67
+ const dependents=(team.tasks||[]).filter(t=>graphDependencies(t).includes(task.id)).map(t=>t.id);
68
+ return '<div id="graph-detail" class="graph-detail" aria-live="polite">'+
69
+ '<h3 class="text-txt-100 font-semibold">'+E(task.content)+'</h3><div class="text-txt-400 mt-1">'+E(task.id)+' · '+chip(E(String(task.status).replace(/_/g,' ')),info.color)+'</div>'+
70
+ '<div class="mt-2">Agent: '+(member?'<button type="button" class="text-blue-400 underline" data-graph-agent="'+E(member.name)+'">'+E(member.name)+' — open agent detail</button>':E(task.assignee||'Unassigned'))+'</div>'+
71
+ '<div class="graph-detail-grid"><div><h4 class="text-txt-300 font-semibold">Prerequisites</h4>'+links(info.deps)+'</div><div><h4 class="text-txt-300 font-semibold">Dependents</h4>'+links(dependents)+'</div></div></div>';
72
+ }
73
+ function graphMarkup(team,layout,selectedId){
74
+ const tasks=team.tasks||[],byId=new Map(tasks.map(task=>[task.id,task])),nodes=new Map(layout.nodes.map(node=>[node.task.id,node]));
75
+ if(!tasks.length)return '<div id="graph-scroll" class="graph-scroll scroll" tabindex="0" aria-label="Task dependency graph"><div class="text-center text-txt-400 text-[13px] py-12">No tasks yet<div class="text-[11px] text-txt-500 mt-2">Add tasks with <code class="font-mono">team_tasks_add</code> to see their dependencies.</div></div></div>'+graphDetail(team,byId,null);
76
+ let warning='';
77
+ if(layout.cyclic.length)warning+='<div class="graph-warning">Cyclic dependencies: '+layout.cyclic.length+' task'+(layout.cyclic.length===1?'':'s')+' affected. Cycle-affected tasks are shown in a fallback column.</div>';
78
+ if(layout.missing.length)warning+='<div class="graph-warning">Missing prerequisite'+(layout.missing.length===1?'':'s')+': '+layout.missing.map(item=>E(byId.get(item.taskId)?.content||item.taskId)+' → '+E(item.dependencyId)).join('; ')+'</div>';
79
+ const skipped=layout.edges.filter(edge=>nodes.get(edge.to).x-nodes.get(edge.from).x>GRAPH_CARD_WIDTH+GRAPH_GAP_X).length;
80
+ const height=layout.height+(skipped?16+skipped*12:0);
81
+ let lane=0;
82
+ const edges=layout.edges.map(edge=>{
83
+ const from=nodes.get(edge.from),to=nodes.get(edge.to),done=from.task.status==='completed';
84
+ const x1=from.x+GRAPH_CARD_WIDTH,y1=from.y+GRAPH_CARD_HEIGHT/2;
85
+ const forward=to.x>from.x,x2=forward?to.x:to.x+GRAPH_CARD_WIDTH,y2=forward?to.y+GRAPH_CARD_HEIGHT/2:to.y+16;
86
+ const bend=forward?(x1+x2)/2:x1+20;
87
+ const skipsColumn=to.x-from.x>GRAPH_CARD_WIDTH+GRAPH_GAP_X;
88
+ const routeY=layout.height+8+lane*12;
89
+ if(skipsColumn)lane++;
90
+ const path=skipsColumn?'M '+x1+' '+y1+' H '+(x1+24)+' V '+routeY+' H '+(x2-24)+' V '+y2+' H '+x2:
91
+ 'M '+x1+' '+y1+' C '+bend+' '+y1+', '+bend+' '+y2+', '+x2+' '+y2;
92
+ return '<path data-edge-from="'+E(edge.from)+'" data-edge-to="'+E(edge.to)+'" d="'+path+'" fill="none" stroke="'+(done?'#4ade80':'#64748b')+'" stroke-width="2" marker-end="url(#graph-arrow-'+(done?'done':'pending')+')"/>';
93
+ }).join('');
94
+ const svg='<svg class="graph-edges" width="'+layout.width+'" height="'+height+'" aria-hidden="true"><defs>'+['done','pending'].map(name=>'<marker id="graph-arrow-'+name+'" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10z" fill="'+(name==='done'?'#4ade80':'#64748b')+'"/></marker>').join('')+'</defs>'+edges+'</svg>';
95
+ const cyclic=new Set(layout.cyclic),cards=layout.nodes.map(node=>graphCard(node,team,byId,selectedId,cyclic.has(node.task.id))).join('');
96
+ return warning+'<div id="graph-scroll" class="graph-scroll scroll" tabindex="0" aria-label="Task dependency graph" aria-describedby="graph-description"><div class="graph-canvas" style="width:'+layout.width+'px;height:'+height+'px">'+svg+cards+'</div></div>'+
97
+ '<div class="graph-legend"><span class="text-emerald-400">✓ Completed prerequisite</span><span class="text-blue-400">● In progress</span><span class="text-amber-400">○ Blocked</span><span>→ Dependency direction</span></div>'+graphDetail(team,byId,selectedId);
98
+ }
99
+ `;
package/src/dashboard.ts CHANGED
@@ -4,13 +4,16 @@ import type { Database } from "./db"
4
4
  import { DASHBOARD_HEAD } from "./dashboard-html"
5
5
  import { DASHBOARD_JS_CORE } from "./dashboard-js-core"
6
6
  import { DASHBOARD_JS_EVENTS } from "./dashboard-js-events"
7
+ import { DASHBOARD_JS_GRAPH_LAYOUT } from "./dashboard-js-graph-layout"
8
+ import { DASHBOARD_JS_GRAPH } from "./dashboard-js-graph"
7
9
  import { DASHBOARD_JS_RENDER } from "./dashboard-js-render"
10
+ import { parseTaskDependencies } from "./task-graph"
8
11
  import { log } from "./log"
9
12
  import type { ActivityBuffer, ActivityEntry } from "./activity"
10
13
  import type { PluginClient } from "./types"
11
14
 
12
15
  /** Assemble the full dashboard HTML from parts. */
13
- const DASHBOARD_HTML = DASHBOARD_HEAD + "\n<script>" + DASHBOARD_JS_CORE + DASHBOARD_JS_RENDER + DASHBOARD_JS_EVENTS + "<\/script>\n</body></html>"
16
+ const DASHBOARD_HTML = DASHBOARD_HEAD + "\n<script>" + DASHBOARD_JS_CORE + DASHBOARD_JS_GRAPH_LAYOUT + DASHBOARD_JS_RENDER + DASHBOARD_JS_GRAPH + DASHBOARD_JS_EVENTS + "<\/script>\n</body></html>"
14
17
 
15
18
  interface TeamRow {
16
19
  id: string
@@ -72,21 +75,6 @@ interface MessageRow {
72
75
  time_created: number
73
76
  }
74
77
 
75
- function parseDependsOn(value: string | null): string[] {
76
- if (!value) return []
77
-
78
- try {
79
- const parsed: unknown = JSON.parse(value)
80
-
81
- if (Array.isArray(parsed)) return parsed.filter((item): item is string => typeof item === "string")
82
- if (typeof parsed === "string") return [parsed]
83
- } catch {
84
- return [value]
85
- }
86
-
87
- return []
88
- }
89
-
90
78
  /**
91
79
  * Bare-integer version of the `/api/state` response shape. Bump on any
92
80
  * breaking change to the payload (field removed/renamed/retyped, not
@@ -154,7 +142,7 @@ function buildState(db: Database): EnsembleDashboardState {
154
142
  status: tk.status,
155
143
  priority: tk.priority,
156
144
  assignee: tk.assignee,
157
- dependsOn: parseDependsOn(tk.depends_on),
145
+ dependsOn: parseTaskDependencies(tk.depends_on),
158
146
  timeCreated: tk.time_created,
159
147
  timeUpdated: tk.time_updated,
160
148
  })),
@@ -207,6 +195,8 @@ export interface DashboardServer {
207
195
 
208
196
  /** Optional dependencies for the dashboard server. */
209
197
  export interface DashboardOptions {
198
+ /** Network interface to bind (defaults to loopback). */
199
+ host?: string
210
200
  /** In-memory activity buffer for real-time per-session events. */
211
201
  activityBuffer?: ActivityBuffer
212
202
  /** SDK client for on-demand session message retrieval. */
@@ -216,7 +206,6 @@ export interface DashboardOptions {
216
206
  function sendJson(res: ServerResponse, data: unknown): void {
217
207
  res.writeHead(200, {
218
208
  "Content-Type": "application/json",
219
- "Access-Control-Allow-Origin": "*",
220
209
  })
221
210
  res.end(JSON.stringify(data))
222
211
  }
@@ -424,7 +413,7 @@ export async function startDashboard(db: Database, port: number, options?: Dashb
424
413
  resolve(null)
425
414
  })
426
415
 
427
- server.listen(port, () => {
416
+ server.listen(port, options?.host ?? "127.0.0.1", () => {
428
417
  log(`dashboard:started port=${port} url=http://localhost:${port}`)
429
418
  resolve(toDashboardServer(server))
430
419
  })
package/src/hooks.ts CHANGED
@@ -242,9 +242,9 @@ export interface SessionErrorPayload {
242
242
  * as a system message to the lead, so otherwise-silent failures are visible.
243
243
  *
244
244
  * The lead is actively woken via notifyLead so the message is delivered even
245
- * when the errored teammate was the last busy member (the "all done" gate in
246
- * the passive wake path would otherwise strand the message). The teammate
247
- * itself is NOT auto-resumed — recovery is left to the lead's judgement.
245
+ * when the errored teammate was the last busy member (the passive wake path
246
+ * only retries on the lead's next idle event). The teammate itself is NOT
247
+ * auto-resumed — recovery is left to the lead's judgement.
248
248
  *
249
249
  * Ignored when:
250
250
  * - sessionID is undefined
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation
14
14
  import { notifyTeamEvent, notifyWorkingProgress, notifyLead } from "./notify"
15
15
  import { hasReportedCompletion } from "./messaging"
16
16
  import { getMemberModel } from "./member-model"
17
+ import { wakeLeadIfPending } from "./wake"
17
18
  import { buildLeadSystemPrompt, buildTeammateSystemPrompt, buildTeamCompactionContext } from "./system-prompt"
18
19
  import { log, initLog } from "./log"
19
20
  import { findTeamBySession } from "./types"
@@ -64,8 +65,6 @@ const plugin: Plugin = async (input) => {
64
65
  const nudgedMembers = new Set<string>()
65
66
  const progressTracker = new ProgressTracker()
66
67
  const activityBuffer = new ActivityBuffer()
67
- const wakeLeadTimestamps = new Map<string, number>()
68
- const WAKE_LEAD_COOLDOWN_MS = 5000
69
68
 
70
69
  // Extract the working HeyAPI transport from the plugin-provided v1 client and pass it
71
70
  // to the v2 OpencodeClient. The plugin framework provides a v1 client which stores its
@@ -131,7 +130,7 @@ const plugin: Plugin = async (input) => {
131
130
 
132
131
  // Start dashboard server (main instance only, not worktree instances)
133
132
  if (config.dashboardPort !== 0) {
134
- startDashboard(db, config.dashboardPort, { activityBuffer, client }).catch((err) => {
133
+ startDashboard(db, config.dashboardPort, { activityBuffer, client, host: config.dashboardHost }).catch((err) => {
135
134
  log(`init:dashboard:failed err=${err instanceof Error ? err.message : String(err)}`)
136
135
  })
137
136
  }
@@ -271,27 +270,16 @@ const plugin: Plugin = async (input) => {
271
270
  await notifyWorkingProgress(client, db, transition.teamId)
272
271
  }
273
272
 
274
- // Wake the lead when it goes idle and has pending messages.
273
+ // Wake the lead when it goes idle and has unseen messages.
275
274
  // The system prompt transform delivers the actual message content.
276
275
  if (statusType === "idle") {
277
276
  const team = db.query("SELECT id FROM team WHERE lead_session_id = ? AND status = 'active'").get(sessionID) as { id: string } | null
278
277
  if (team) {
279
- const pending = db.query("SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND to_name = 'lead' AND delivered = 0").get(team.id) as { c: number }
280
- // Skip wake if all teammates are done or if we woke recently (issue #3 — breaks completion loop)
281
- const allDone = (db.query(
282
- "SELECT COUNT(*) as c FROM team_member WHERE team_id = ? AND status NOT IN ('ready', 'shutdown', 'error')"
283
- ).get(team.id) as { c: number }).c === 0
284
- const lastWake = wakeLeadTimestamps.get(team.id) ?? 0
285
- if (pending.c > 0 && !allDone && Date.now() - lastWake > WAKE_LEAD_COOLDOWN_MS) {
286
- wakeLeadTimestamps.set(team.id, Date.now())
287
- log(`wake-lead: ${pending.c} pending messages, sending promptAsync`)
288
- client.session.promptAsync({
289
- sessionID,
290
- parts: [{ type: "text", text: `[System: ${pending.c} new team message(s) available]` }],
291
- }).catch((err) => {
292
- log(`wake-lead:failed err=${err instanceof Error ? err.message : String(err)}`)
293
- })
294
- }
278
+ // Guarded/coalesced: skips if the team was archived or the inbox
279
+ // was already drained while this idle event was in flight (#48, #57).
280
+ wakeLeadIfPending(client, db, team.id).catch((err) => {
281
+ log(`wake-lead:failed err=${err instanceof Error ? err.message : String(err)}`)
282
+ })
295
283
  }
296
284
 
297
285
  // Also flush peer messages for teammates that just went idle
@@ -304,14 +292,14 @@ const plugin: Plugin = async (input) => {
304
292
  if (member && !hasReportedCompletion(db, member.team_id, member.name)) {
305
293
  const staleThreshold = Date.now() - 5000
306
294
  const peerMsgs = db.query(
307
- "SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND to_name = ? AND delivered = 0 AND time_created < ?"
295
+ "SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND to_name = ? AND read = 0 AND delivered = 0 AND time_created < ?"
308
296
  ).get(member.team_id, member.name, staleThreshold) as { c: number }
309
297
  if (peerMsgs.c > 0) {
310
298
  log(`wake-peer: ${member.name} has ${peerMsgs.c} pending peer messages`)
311
299
  const peerModel = getMemberModel(db, member.team_id, member.name)
312
300
  client.session.promptAsync({
313
301
  sessionID,
314
- parts: [{ type: "text", text: `[System: ${peerMsgs.c} new message(s) from teammates]` }],
302
+ parts: [{ type: "text", text: `[System: ${peerMsgs.c} new message(s) from teammates — use team_results to read them]` }],
315
303
  ...(peerModel ? { model: peerModel } : {}),
316
304
  }).catch((err) => {
317
305
  log(`wake-peer:failed err=${err instanceof Error ? err.message : String(err)}`)
@@ -386,7 +374,7 @@ const plugin: Plugin = async (input) => {
386
374
  log(`system-prompt:transform role=${teamInfo.role} session=${input.sessionID}`)
387
375
  const prompt = teamInfo.role === "lead"
388
376
  ? buildLeadSystemPrompt(db, teamInfo.teamId, config)
389
- : buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName ?? "unknown")
377
+ : buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName ?? "unknown", config)
390
378
  log(`system-prompt:injected role=${teamInfo.role} len=${prompt.length}`)
391
379
  output.system.push(prompt)
392
380
  },
@@ -1,20 +1,41 @@
1
1
  import type { Database } from "./db"
2
+ import type { PluginClient } from "./types"
2
3
 
3
4
  /** A parsed provider/model pair, as accepted by session.promptAsync. */
4
5
  export interface ParsedModel {
5
6
  providerID: string
6
7
  modelID: string
8
+ variant?: string
9
+ }
10
+
11
+ /** A session's current agent and model selection. Either may be unknown. */
12
+ export interface SessionSelection {
13
+ agent?: string
14
+ model?: ParsedModel
15
+ }
16
+
17
+ /** Format a parsed model back into "provider/model" (with "#variant" when set). */
18
+ export function formatModelId(model: ParsedModel): string {
19
+ return `${model.providerID}/${model.modelID}${model.variant ? `#${model.variant}` : ""}`
7
20
  }
8
21
 
9
22
  /**
10
23
  * Parse a "provider/model" string into { providerID, modelID } for the SDK.
11
24
  * Returns undefined if the string is not in "provider/model" form (empty
12
25
  * provider or empty model). The model portion may itself contain slashes.
26
+ * A trailing "#variant" selects a model variant, e.g. "openai/gpt-6.1-terra#max".
13
27
  */
14
28
  export function parseModelId(model: string): ParsedModel | undefined {
15
- const slash = model.indexOf("/")
16
- if (slash <= 0 || slash === model.length - 1) return undefined
17
- return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) }
29
+ const hash = model.indexOf("#")
30
+ const variant = hash === -1 ? undefined : model.slice(hash + 1) || undefined
31
+ const base = hash === -1 ? model : model.slice(0, hash)
32
+ const slash = base.indexOf("/")
33
+ if (slash <= 0 || slash === base.length - 1) return undefined
34
+ return {
35
+ providerID: base.slice(0, slash),
36
+ modelID: base.slice(slash + 1),
37
+ ...(variant ? { variant } : {}),
38
+ }
18
39
  }
19
40
 
20
41
  /**
@@ -28,3 +49,86 @@ export function getMemberModel(db: Database, teamId: string, memberName: string)
28
49
  if (!row?.model) return undefined
29
50
  return parseModelId(row.model)
30
51
  }
52
+
53
+ /** Text prefixes used by Ensemble-injected wake/delivery messages. */
54
+ const ENSEMBLE_MESSAGE_PREFIXES = [
55
+ "[System:",
56
+ "[System]",
57
+ "[Team message from ",
58
+ "[Recovered team message from ",
59
+ ]
60
+
61
+ /** Whether every text part in a message is an Ensemble-injected notification. */
62
+ function isEnsembleInjected(parts: unknown[]): boolean {
63
+ const textParts = parts.filter((part): part is { type: "text"; text: string } => {
64
+ if (!part || typeof part !== "object") return false
65
+ const candidate = part as { type?: unknown; text?: unknown }
66
+ return candidate.type === "text" && typeof candidate.text === "string"
67
+ })
68
+ if (textParts.length === 0) return false
69
+ return textParts.every(part => ENSEMBLE_MESSAGE_PREFIXES.some(prefix => part.text.startsWith(prefix)))
70
+ }
71
+
72
+ /** Accept both V1 ({ providerID, modelID }) and V2 ({ providerID, id }) model refs. */
73
+ function parseSessionModel(value: unknown): ParsedModel | undefined {
74
+ if (!value || typeof value !== "object") return undefined
75
+ const raw = value as Record<string, unknown>
76
+ const providerID = typeof raw.providerID === "string" ? raw.providerID : undefined
77
+ const modelID = typeof raw.modelID === "string"
78
+ ? raw.modelID
79
+ : typeof raw.id === "string"
80
+ ? raw.id
81
+ : undefined
82
+ const variant = typeof raw.variant === "string" && raw.variant.length > 0 ? raw.variant : undefined
83
+ if (!providerID || !modelID) return undefined
84
+ return { providerID, modelID, ...(variant ? { variant } : {}) }
85
+ }
86
+
87
+ /**
88
+ * Best-effort lookup of a session's current agent and model selection.
89
+ *
90
+ * V2 exposes both directly on session.get. V1 does not, so it falls back to
91
+ * the most recent real user message (skipping Ensemble's own wake messages,
92
+ * which are injected as user turns on V1). Never throws: unknown selection
93
+ * simply returns an empty object so delivery can proceed with defaults.
94
+ */
95
+ export async function getSessionSelection(client: PluginClient, sessionID: string): Promise<SessionSelection> {
96
+ let agent: string | undefined
97
+ let model: ParsedModel | undefined
98
+
99
+ // V2 fast path. `client.agent` is only present on the V2 adapter.
100
+ if (client.agent) {
101
+ try {
102
+ const result = await client.session.get({ sessionID })
103
+ const data = result.data as { agent?: unknown; model?: unknown } | undefined
104
+ if (typeof data?.agent === "string") agent = data.agent
105
+ model = parseSessionModel(data?.model)
106
+ } catch {
107
+ // Fall through to the message-based lookup.
108
+ }
109
+ }
110
+
111
+ if (!agent || !model) {
112
+ try {
113
+ const result = await client.session.messages({ sessionID, limit: 100 })
114
+ const messages = result.data ?? []
115
+ for (let i = messages.length - 1; i >= 0; i--) {
116
+ const message = messages[i]
117
+ if (!message) continue
118
+ const info = message.info as { role?: unknown; agent?: unknown; model?: unknown } | undefined
119
+ if (info?.role !== "user") continue
120
+ if (isEnsembleInjected(message.parts)) continue
121
+ if (!agent && typeof info.agent === "string") agent = info.agent
122
+ if (!model) model = parseSessionModel(info.model)
123
+ if (agent && model) break
124
+ }
125
+ } catch {
126
+ // Best effort only.
127
+ }
128
+ }
129
+
130
+ return {
131
+ ...(agent ? { agent } : {}),
132
+ ...(model ? { model } : {}),
133
+ }
134
+ }