@hueyexe/opencode-ensemble 0.20.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/README.md +29 -16
- package/dist/index.js +13617 -13116
- package/package.json +1 -1
- package/src/config.ts +33 -0
- package/src/dashboard-html.ts +34 -1
- package/src/dashboard-js-events.ts +9 -0
- package/src/dashboard-js-graph-layout.ts +40 -0
- package/src/dashboard-js-graph.ts +99 -0
- package/src/dashboard.ts +8 -19
- package/src/hooks.ts +3 -3
- package/src/index.ts +11 -23
- package/src/member-model.ts +107 -3
- package/src/messaging.ts +4 -2
- package/src/notify.ts +9 -18
- package/src/recovery.ts +23 -16
- package/src/schema.ts +4 -0
- package/src/system-prompt.ts +79 -50
- package/src/task-graph.ts +116 -0
- package/src/tools/team-broadcast.ts +21 -3
- package/src/tools/team-message.ts +4 -5
- package/src/tools/team-results.ts +15 -5
- package/src/tools/team-spawn.ts +41 -13
- package/src/tools/team-tasks-add.ts +41 -5
- package/src/tools/team-tasks-complete.ts +2 -1
- package/src/types.ts +1 -1
- package/src/v2-client.ts +5 -1
- package/src/v2-setup.ts +2 -2
- package/src/wake.ts +198 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hueyexe/opencode-ensemble",
|
|
3
|
-
"version": "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",
|
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 {}
|
package/src/dashboard-html.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
246
|
-
*
|
|
247
|
-
*
|
|
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
|
|
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
|
-
|
|
280
|
-
//
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
},
|
package/src/member-model.ts
CHANGED
|
@@ -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
|
|
16
|
-
|
|
17
|
-
|
|
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
|
+
}
|
package/src/messaging.ts
CHANGED
|
@@ -62,11 +62,13 @@ export function broadcastMessage(db: Database, input: BroadcastMessageInput): st
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
|
-
* Get all undelivered messages for a team.
|
|
65
|
+
* Get all undelivered messages for a team. Messages already read via
|
|
66
|
+
* team_results are excluded — re-injecting or re-sending them would duplicate
|
|
67
|
+
* content the recipient has already seen.
|
|
66
68
|
*/
|
|
67
69
|
export function getUndeliveredMessages(db: Database, teamId: string): MessageRow[] {
|
|
68
70
|
return db.query(
|
|
69
|
-
"SELECT * FROM team_message WHERE team_id = ? AND delivered = 0 ORDER BY time_created ASC"
|
|
71
|
+
"SELECT * FROM team_message WHERE team_id = ? AND read = 0 AND delivered = 0 ORDER BY time_created ASC"
|
|
70
72
|
).all(teamId) as MessageRow[]
|
|
71
73
|
}
|
|
72
74
|
|