@hueyexe/opencode-ensemble 0.15.1 → 0.16.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 +4 -4
- package/dist/index.js +1592 -316
- package/package.json +3 -3
- package/src/activity.ts +215 -0
- package/src/client.ts +3 -1
- package/src/dashboard-html.ts +1 -0
- package/src/dashboard-js-core.ts +1 -1
- package/src/dashboard-js-events.ts +27 -1
- package/src/dashboard-js-render.ts +94 -0
- package/src/dashboard.ts +123 -5
- package/src/index.ts +29 -2
- package/src/types.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hueyexe/opencode-ensemble",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"typescript": "^5"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@opencode-ai/plugin": "^1.
|
|
49
|
-
"@opencode-ai/sdk": "^1.
|
|
48
|
+
"@opencode-ai/plugin": "^1.17.18",
|
|
49
|
+
"@opencode-ai/sdk": "^1.17.18"
|
|
50
50
|
}
|
|
51
51
|
}
|
package/src/activity.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import type { MemberRegistry } from "./state"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A single activity event captured per session for the dashboard verbose view.
|
|
5
|
+
*/
|
|
6
|
+
export interface ActivityEntry {
|
|
7
|
+
/** The kind of activity being recorded. */
|
|
8
|
+
type: "tool_call" | "tool_result" | "shell_command" | "step" | "reasoning" | "file" | "text"
|
|
9
|
+
/** Tool name (for tool_call / tool_result types). */
|
|
10
|
+
tool?: string
|
|
11
|
+
/** Human-readable title for the activity. */
|
|
12
|
+
title?: string
|
|
13
|
+
/** Structured input for a tool call (JSON-formatted string if object). */
|
|
14
|
+
input?: string
|
|
15
|
+
/** Structured output for a tool result (JSON-formatted string if object). */
|
|
16
|
+
output?: string
|
|
17
|
+
/** Error message if the activity failed. */
|
|
18
|
+
error?: string
|
|
19
|
+
/** Shell command string (for shell_command type). */
|
|
20
|
+
command?: string
|
|
21
|
+
/** Exit code for shell commands. */
|
|
22
|
+
exitCode?: number
|
|
23
|
+
/** Input token count for the activity. */
|
|
24
|
+
tokensIn?: number
|
|
25
|
+
/** Output token count for the activity. */
|
|
26
|
+
tokensOut?: number
|
|
27
|
+
/** Cost in dollars for the activity. */
|
|
28
|
+
cost?: number
|
|
29
|
+
/** Unix millisecond timestamp of the event. */
|
|
30
|
+
timestamp: number
|
|
31
|
+
/** Reasoning text (for reasoning type — model's chain of thought). */
|
|
32
|
+
reasoning?: string
|
|
33
|
+
/** File path (for file type). */
|
|
34
|
+
filePath?: string
|
|
35
|
+
/** File content (for file type). */
|
|
36
|
+
fileContent?: string
|
|
37
|
+
/** File diff (for file type). */
|
|
38
|
+
fileDiff?: string
|
|
39
|
+
/** Text content (for text type — prompts and responses). */
|
|
40
|
+
text?: string
|
|
41
|
+
/** Role for text entries: "user" or "assistant". */
|
|
42
|
+
role?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Options for constructing an {@link ActivityBuffer}. */
|
|
46
|
+
export interface ActivityBufferOptions {
|
|
47
|
+
/** Maximum number of entries retained per session (default 100). */
|
|
48
|
+
maxPerSession?: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* In-memory rolling buffer of per-session activity events.
|
|
53
|
+
*
|
|
54
|
+
* Captures tool calls, shell commands, and step transitions for the dashboard
|
|
55
|
+
* verbose view. Entries are capped at `maxPerSession` per session — when the
|
|
56
|
+
* limit is exceeded the oldest entries are evicted.
|
|
57
|
+
*/
|
|
58
|
+
export class ActivityBuffer {
|
|
59
|
+
private readonly buffers: Map<string, ActivityEntry[]> = new Map()
|
|
60
|
+
private readonly maxPerSession: number
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param options Optional configuration. `maxPerSession` defaults to 100.
|
|
64
|
+
*/
|
|
65
|
+
constructor(options?: ActivityBufferOptions) {
|
|
66
|
+
this.maxPerSession = options?.maxPerSession ?? 100
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Add an activity entry to the session's buffer.
|
|
71
|
+
* When the buffer exceeds `maxPerSession`, the oldest entries are dropped.
|
|
72
|
+
* @param sessionID The session to record activity for.
|
|
73
|
+
* @param entry The activity event to store.
|
|
74
|
+
*/
|
|
75
|
+
record(sessionID: string, entry: ActivityEntry): void {
|
|
76
|
+
const entries = this.buffers.get(sessionID) ?? []
|
|
77
|
+
entries.push(entry)
|
|
78
|
+
if (entries.length > this.maxPerSession) {
|
|
79
|
+
entries.splice(0, entries.length - this.maxPerSession)
|
|
80
|
+
}
|
|
81
|
+
this.buffers.set(sessionID, entries)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Retrieve activity entries for a session in chronological order (oldest first).
|
|
86
|
+
* @param sessionID The session to query.
|
|
87
|
+
* @param limit If provided, return only the N most recent entries.
|
|
88
|
+
* @returns Array of entries (empty if session has no recorded activity).
|
|
89
|
+
*/
|
|
90
|
+
getActivity(sessionID: string, limit?: number): ActivityEntry[] {
|
|
91
|
+
const entries = this.buffers.get(sessionID)
|
|
92
|
+
if (!entries) return []
|
|
93
|
+
if (limit !== undefined) return entries.slice(-limit)
|
|
94
|
+
return [...entries]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Remove all entries for a session.
|
|
99
|
+
* @param sessionID The session to clear.
|
|
100
|
+
*/
|
|
101
|
+
remove(sessionID: string): void {
|
|
102
|
+
this.buffers.delete(sessionID)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Check whether any entries exist for a session.
|
|
107
|
+
* @param sessionID The session to check.
|
|
108
|
+
* @returns `true` if at least one entry exists, `false` otherwise.
|
|
109
|
+
*/
|
|
110
|
+
has(sessionID: string): boolean {
|
|
111
|
+
const entries = this.buffers.get(sessionID)
|
|
112
|
+
return entries !== undefined && entries.length > 0
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** V2 event shape for activity recording (permissive — SDK types not available in plugin). */
|
|
117
|
+
interface V2Event {
|
|
118
|
+
type: string
|
|
119
|
+
properties: {
|
|
120
|
+
sessionID?: string
|
|
121
|
+
tool?: string
|
|
122
|
+
input?: string
|
|
123
|
+
content?: string
|
|
124
|
+
title?: string
|
|
125
|
+
error?: string
|
|
126
|
+
command?: string
|
|
127
|
+
exitCode?: number
|
|
128
|
+
cost?: number
|
|
129
|
+
tokens?: { input?: number; output?: number }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Record activity from a v2 session event (shell commands and step transitions only).
|
|
135
|
+
* Tool call/result recording is handled by {@link recordFromToolBefore} and {@link recordFromToolAfter}
|
|
136
|
+
* to avoid duplicate entries from both the event hook and the tool.execute hook.
|
|
137
|
+
* Only records for sessions registered as team members.
|
|
138
|
+
* @param event The v2 event from the plugin event hook.
|
|
139
|
+
* @param registry The member registry for session lookup.
|
|
140
|
+
* @param buffer The activity buffer to record into.
|
|
141
|
+
*/
|
|
142
|
+
export function recordFromV2Event(
|
|
143
|
+
event: V2Event,
|
|
144
|
+
registry: MemberRegistry,
|
|
145
|
+
buffer: ActivityBuffer,
|
|
146
|
+
): void {
|
|
147
|
+
const props = event.properties
|
|
148
|
+
if (!props?.sessionID || !registry.getBySession(props.sessionID)) return
|
|
149
|
+
|
|
150
|
+
if (event.type === "session.next.shell.started") {
|
|
151
|
+
buffer.record(props.sessionID, { type: "shell_command", command: props.command, timestamp: Date.now() })
|
|
152
|
+
} else if (event.type === "session.next.shell.ended") {
|
|
153
|
+
buffer.record(props.sessionID, { type: "shell_command", exitCode: props.exitCode, timestamp: Date.now() })
|
|
154
|
+
} else if (event.type === "session.next.step.ended") {
|
|
155
|
+
buffer.record(props.sessionID, {
|
|
156
|
+
type: "step",
|
|
157
|
+
cost: props.cost,
|
|
158
|
+
tokensIn: props.tokens?.input,
|
|
159
|
+
tokensOut: props.tokens?.output,
|
|
160
|
+
timestamp: Date.now(),
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Input shape for the tool.execute.before hook. */
|
|
166
|
+
interface ToolBeforeInput {
|
|
167
|
+
sessionID: string
|
|
168
|
+
tool: string
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Output shape for the tool.execute.after hook. */
|
|
172
|
+
interface ToolAfterOutput {
|
|
173
|
+
title?: string
|
|
174
|
+
output?: string
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Record a tool call from the tool.execute.before hook.
|
|
179
|
+
* Only records for sessions registered as team members.
|
|
180
|
+
* @param input The tool.execute.before input.
|
|
181
|
+
* @param registry The member registry for session lookup.
|
|
182
|
+
* @param buffer The activity buffer to record into.
|
|
183
|
+
*/
|
|
184
|
+
export function recordFromToolBefore(
|
|
185
|
+
input: ToolBeforeInput,
|
|
186
|
+
registry: MemberRegistry,
|
|
187
|
+
buffer: ActivityBuffer,
|
|
188
|
+
): void {
|
|
189
|
+
if (!registry.getBySession(input.sessionID)) return
|
|
190
|
+
buffer.record(input.sessionID, { type: "tool_call", tool: input.tool, timestamp: Date.now() })
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Record a tool result from the tool.execute.after hook.
|
|
195
|
+
* Only records for sessions registered as team members.
|
|
196
|
+
* @param input The tool.execute.before input (for session ID and tool name).
|
|
197
|
+
* @param output The tool.execute.after output.
|
|
198
|
+
* @param registry The member registry for session lookup.
|
|
199
|
+
* @param buffer The activity buffer to record into.
|
|
200
|
+
*/
|
|
201
|
+
export function recordFromToolAfter(
|
|
202
|
+
input: ToolBeforeInput,
|
|
203
|
+
output: ToolAfterOutput,
|
|
204
|
+
registry: MemberRegistry,
|
|
205
|
+
buffer: ActivityBuffer,
|
|
206
|
+
): void {
|
|
207
|
+
if (!registry.getBySession(input.sessionID)) return
|
|
208
|
+
buffer.record(input.sessionID, {
|
|
209
|
+
type: "tool_result",
|
|
210
|
+
tool: input.tool,
|
|
211
|
+
title: output.title,
|
|
212
|
+
output: output.output,
|
|
213
|
+
timestamp: Date.now(),
|
|
214
|
+
})
|
|
215
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -22,7 +22,7 @@ function throwing(fn: SdkMethod): SdkMethod {
|
|
|
22
22
|
|
|
23
23
|
/** Shape of the raw v2 SDK client — just the methods we wrap. */
|
|
24
24
|
interface RawClient {
|
|
25
|
-
session: { create: SdkMethod; promptAsync: SdkMethod; abort: SdkMethod; status: SdkMethod }
|
|
25
|
+
session: { create: SdkMethod; promptAsync: SdkMethod; abort: SdkMethod; status: SdkMethod; messages: SdkMethod; get: SdkMethod }
|
|
26
26
|
tui: { showToast: SdkMethod; selectSession: SdkMethod }
|
|
27
27
|
worktree: { create: SdkMethod; remove: SdkMethod; list: SdkMethod; reset: SdkMethod }
|
|
28
28
|
experimental: { workspace: { create: SdkMethod; remove: SdkMethod; list: SdkMethod } }
|
|
@@ -40,6 +40,8 @@ export function wrapThrowingClient(raw: unknown): PluginClient {
|
|
|
40
40
|
promptAsync: throwing(r.session.promptAsync.bind(r.session)),
|
|
41
41
|
abort: throwing(r.session.abort.bind(r.session)),
|
|
42
42
|
status: throwing(r.session.status.bind(r.session)),
|
|
43
|
+
messages: throwing(r.session.messages.bind(r.session)),
|
|
44
|
+
get: throwing(r.session.get.bind(r.session)),
|
|
43
45
|
},
|
|
44
46
|
tui: {
|
|
45
47
|
showToast: throwing(r.tui.showToast.bind(r.tui)),
|
package/src/dashboard-html.ts
CHANGED
|
@@ -95,6 +95,7 @@ select{-webkit-appearance:none;appearance:none;background-image:url("data:image/
|
|
|
95
95
|
<div><kbd class="px-1.5 py-0.5 bg-base-800 rounded font-mono text-txt-300 text-[11px]">k</kbd> <span class="text-txt-400">Prev agent</span></div>
|
|
96
96
|
<div><kbd class="px-1.5 py-0.5 bg-base-800 rounded font-mono text-txt-300 text-[11px]">Enter</kbd> <span class="text-txt-400">Expand agent</span></div>
|
|
97
97
|
<div><kbd class="px-1.5 py-0.5 bg-base-800 rounded font-mono text-txt-300 text-[11px]">Esc</kbd> <span class="text-txt-400">Collapse all</span></div>
|
|
98
|
+
<div><kbd class="px-1.5 py-0.5 bg-base-800 rounded font-mono text-txt-300 text-[11px]">v</kbd> <span class="text-txt-400">Toggle verbose</span></div>
|
|
98
99
|
<div><kbd class="px-1.5 py-0.5 bg-base-800 rounded font-mono text-txt-300 text-[11px]">1-9</kbd> <span class="text-txt-400">Switch team</span></div>
|
|
99
100
|
<div><kbd class="px-1.5 py-0.5 bg-base-800 rounded font-mono text-txt-300 text-[11px]">?</kbd> <span class="text-txt-400">This help</span></div>
|
|
100
101
|
</div>
|
package/src/dashboard-js-core.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Dashboard JS — utilities, data helpers, and state management. */
|
|
2
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;
|
|
3
|
+
let S=null,selId=null,selProjectId=null,fails=0,pollT=Date.now(),prevMC=0,selCard=-1,navCollapsed=false,verbose=(function(){try{return localStorage.getItem('ensemble-verbose')==='1'}catch(e){return false}})(),drawerActivity=null,drawerSession=null;
|
|
4
4
|
const expCards=new Set(),expMsgs=new Set();
|
|
5
5
|
const E=s=>s?String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'):'';
|
|
6
6
|
const D=ms=>{const s=Math.floor(Math.abs(ms)/1000);return s<60?s+'s':s<3600?Math.floor(s/60)+'m':Math.floor(s/3600)+'h'};
|
|
@@ -2,6 +2,31 @@
|
|
|
2
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 toggleVerbose(){
|
|
6
|
+
verbose=!verbose;
|
|
7
|
+
try{localStorage.setItem('ensemble-verbose',verbose?'1':'0')}catch(e){}
|
|
8
|
+
var btn=document.getElementById('verbose-toggle');
|
|
9
|
+
if(btn){
|
|
10
|
+
btn.textContent='verbose: '+(verbose?'on':'off');
|
|
11
|
+
btn.setAttribute('aria-pressed',verbose?'true':'false');
|
|
12
|
+
btn.className='text-[10px] '+(verbose?'text-blue-400 border-blue-500/40 bg-blue-500/10':'text-txt-500 hover:text-txt-200 border-base-800')+' rounded px-1.5 py-[2px] transition-colors';
|
|
13
|
+
}
|
|
14
|
+
rDrawerActivityUpdate();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
var fetchActivityGen=0;
|
|
18
|
+
async function fetchActivity(sessionId){
|
|
19
|
+
var gen=++fetchActivityGen;
|
|
20
|
+
try{
|
|
21
|
+
var res=await fetch('api/session/'+encodeURIComponent(sessionId)+'/activity');
|
|
22
|
+
var data=await res.json();
|
|
23
|
+
if(gen!==fetchActivityGen)return;
|
|
24
|
+
drawerActivity=data.activity||[];
|
|
25
|
+
drawerSession=data.session||null;
|
|
26
|
+
rDrawerActivityUpdate();
|
|
27
|
+
}catch{if(gen!==fetchActivityGen)return;drawerActivity=[];drawerSession=null;rDrawerActivityUpdate()}
|
|
28
|
+
}
|
|
29
|
+
|
|
5
30
|
function applyNavCollapse(){
|
|
6
31
|
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
32
|
content.classList.toggle('nav-collapsed',navCollapsed);
|
|
@@ -32,7 +57,7 @@ function conn(ok){
|
|
|
32
57
|
document.getElementById('ct').textContent=ok?D(Date.now()-pollT)+' ago':'reconnecting to dashboard state';
|
|
33
58
|
}
|
|
34
59
|
|
|
35
|
-
async function poll(){try{S=await(await fetch('
|
|
60
|
+
async function poll(){try{S=await(await fetch('api/state')).json();fails=0;pollT=Date.now();conn(true);render()}catch{if(++fails>=3)conn(false)}}
|
|
36
61
|
|
|
37
62
|
function setBackgroundInert(locked){
|
|
38
63
|
document.querySelectorAll('header,main,#sum,#tl').forEach(function(el){
|
|
@@ -96,6 +121,7 @@ document.addEventListener('keydown',function(e){
|
|
|
96
121
|
if(e.key==='Escape'){closeDrawer();expMsgs.clear();selCard=-1;closeShortcuts();render();return}
|
|
97
122
|
if(e.key==='j'&&mm.length){e.preventDefault();selCard=Math.min(selCard+1,mm.length-1);render();return}
|
|
98
123
|
if(e.key==='k'&&mm.length){e.preventDefault();selCard=Math.max(selCard-1,0);render();return}
|
|
124
|
+
if(e.key==='v'&&!e.ctrlKey&&!e.metaKey&&!e.altKey){e.preventDefault();toggleVerbose();return}
|
|
99
125
|
if(e.key==='Enter'&&selCard>=0&&selCard<mm.length){e.preventDefault();openDrawer(mm[selCard].name);return}
|
|
100
126
|
if(e.key>='1'&&e.key<='9'){
|
|
101
127
|
var teams=allTeams(),all=[...teams.active,...teams.archived];
|
|
@@ -151,6 +151,14 @@ function openDrawer(name){
|
|
|
151
151
|
h+='</div></div>';
|
|
152
152
|
});
|
|
153
153
|
}
|
|
154
|
+
// Activity timeline
|
|
155
|
+
h+='<div class="mt-4 pt-4 border-t border-base-800/50">';
|
|
156
|
+
h+='<div class="flex items-center justify-between mb-3">';
|
|
157
|
+
h+='<span class="text-txt-400 text-[10px] uppercase tracking-wider">Activity</span>';
|
|
158
|
+
h+='<button type="button" id="verbose-toggle" onclick="toggleVerbose()" aria-pressed="'+(verbose?'true':'false')+'" class="text-[10px] '+(verbose?'text-blue-400 border-blue-500/40 bg-blue-500/10':'text-txt-500 hover:text-txt-200 border-base-800')+' rounded px-1.5 py-[2px] transition-colors">verbose: '+(verbose?'on':'off')+'</button>';
|
|
159
|
+
h+='</div>';
|
|
160
|
+
h+='<div id="drawer-activity-list"><div class="text-txt-500 text-[12px]">Loading activity...</div></div>';
|
|
161
|
+
h+='</div>';
|
|
154
162
|
var drawer=document.getElementById('drawer');
|
|
155
163
|
drawer.innerHTML=h;
|
|
156
164
|
drawer.classList.add('open');
|
|
@@ -159,6 +167,92 @@ function openDrawer(name){
|
|
|
159
167
|
setBackgroundInert(true);
|
|
160
168
|
drawer.focus();
|
|
161
169
|
document.getElementById('drawer-bg').classList.add('open');
|
|
170
|
+
// Fetch activity for this agent's session
|
|
171
|
+
drawerActivity=null;drawerSession=null;
|
|
172
|
+
if(m.sessionId)fetchActivity(m.sessionId);
|
|
173
|
+
else rDrawerActivityUpdate();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function rDrawerActivityUpdate(){
|
|
177
|
+
var el=document.getElementById('drawer-activity-list');
|
|
178
|
+
if(!el)return;
|
|
179
|
+
if(!drawerActivity||!drawerActivity.length){
|
|
180
|
+
el.innerHTML='<div class="text-txt-500 text-[12px]">No activity recorded</div>';
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
var html=drawerActivity.map(function(a){
|
|
184
|
+
var icon='\\u25CF',color='text-txt-500',label=a.type;
|
|
185
|
+
if(a.type==='tool_call'){icon='\\u25B8';color='text-blue-400';label=a.tool||'tool'}
|
|
186
|
+
if(a.type==='tool_result'){icon='\\u25B8';color=a.error?'text-red-400':'text-emerald-400';label=a.tool||'tool'}
|
|
187
|
+
if(a.type==='shell_command'){icon='$';color='text-amber-400';label='shell'}
|
|
188
|
+
if(a.type==='step'){icon='\\u2261';color='text-violet-400';label='step'}
|
|
189
|
+
if(a.type==='reasoning'){icon='\\u2726';color='text-violet-400';label='reasoning'}
|
|
190
|
+
if(a.type==='file'){icon='\\u25A3';color='text-cyan-400';label=a.filePath?a.filePath.split('/').pop():'file'}
|
|
191
|
+
if(a.type==='text'){icon='\\u00BB';color=a.role==='user'?'text-emerald-400':'text-blue-400';label=a.role==='user'?'prompt':'response'}
|
|
192
|
+
var ts=relT(a.timestamp);
|
|
193
|
+
if(!verbose){
|
|
194
|
+
return '<div class="flex items-center gap-2 py-1 border-b border-base-800/30">'+
|
|
195
|
+
'<span class="'+color+' text-[11px] shrink-0 font-mono">'+icon+'</span>'+
|
|
196
|
+
'<span class="text-[12px] text-txt-400 truncate flex-1">'+E(label)+'</span>'+
|
|
197
|
+
'<span class="text-[10px] text-txt-500 shrink-0">'+ts+'</span>'+
|
|
198
|
+
'</div>';
|
|
199
|
+
}
|
|
200
|
+
if(a.type==='reasoning'){
|
|
201
|
+
return '<details open class="py-2 border-b border-base-800/30">'+
|
|
202
|
+
'<summary class="flex items-center gap-2 cursor-pointer select-none hover:text-txt-100 transition-colors">'+
|
|
203
|
+
'<span class="'+color+' text-[11px] shrink-0 font-mono">'+icon+'</span>'+
|
|
204
|
+
'<span class="text-[12px] text-violet-300 font-medium">Reasoning</span>'+
|
|
205
|
+
'<span class="text-[10px] text-txt-500 ml-auto shrink-0">'+ts+'</span>'+
|
|
206
|
+
'</summary>'+
|
|
207
|
+
'<div class="mt-1.5 ml-5 text-[11px] text-violet-200/60 bg-violet-950/20 rounded-md p-2 border border-violet-800/30 overflow-x-auto whitespace-pre-wrap font-mono max-h-[200px] overflow-y-auto">'+E(a.reasoning||'')+'</div>'+
|
|
208
|
+
'</details>';
|
|
209
|
+
}
|
|
210
|
+
if(a.type==='file'){
|
|
211
|
+
var fname=a.filePath?a.filePath.split('/').pop():'file';
|
|
212
|
+
var fcontent=a.fileDiff||a.fileContent||'';
|
|
213
|
+
return '<details open class="py-2 border-b border-base-800/30">'+
|
|
214
|
+
'<summary class="flex items-center gap-2 cursor-pointer select-none hover:text-txt-100 transition-colors">'+
|
|
215
|
+
'<span class="'+color+' text-[11px] shrink-0 font-mono">'+icon+'</span>'+
|
|
216
|
+
'<span class="text-[12px] text-cyan-300 font-medium truncate flex-1">'+E(a.filePath||fname)+'</span>'+
|
|
217
|
+
'<span class="text-[10px] text-txt-500 shrink-0">'+ts+'</span>'+
|
|
218
|
+
'</summary>'+
|
|
219
|
+
(fcontent?'<div class="mt-1.5 ml-5 text-[11px] text-cyan-200/70 bg-cyan-950/20 rounded-md p-2 border border-cyan-800/30 overflow-x-auto whitespace-pre font-mono max-h-[300px] overflow-y-auto">'+E(fcontent)+'</div>':'')+
|
|
220
|
+
'</details>';
|
|
221
|
+
}
|
|
222
|
+
if(a.type==='text'){
|
|
223
|
+
return '<div class="py-2 border-b border-base-800/30">'+
|
|
224
|
+
'<div class="flex items-start gap-2">'+
|
|
225
|
+
'<span class="'+color+' text-[11px] mt-[1px] shrink-0 font-mono">'+icon+'</span>'+
|
|
226
|
+
'<div class="flex-1 min-w-0">'+
|
|
227
|
+
'<div class="text-[12px] '+(a.role==='user'?'text-emerald-300':'text-blue-300')+' font-medium">'+E(label)+'</div>'+
|
|
228
|
+
'<div class="text-[11px] text-txt-300 mt-1 md">'+md(a.text||'')+'</div>'+
|
|
229
|
+
'</div>'+
|
|
230
|
+
'<span class="text-[10px] text-txt-500 shrink-0">'+ts+'</span>'+
|
|
231
|
+
'</div></div>';
|
|
232
|
+
}
|
|
233
|
+
var title=a.title||a.tool||a.command||label;
|
|
234
|
+
var row='<div class="py-2 border-b border-base-800/30">';
|
|
235
|
+
row+='<div class="flex items-start gap-2">';
|
|
236
|
+
row+='<span class="'+color+' text-[11px] mt-[1px] shrink-0 font-mono">'+icon+'</span>';
|
|
237
|
+
row+='<div class="flex-1 min-w-0">';
|
|
238
|
+
row+='<div class="text-[12px] text-txt-200 font-medium">'+E(title)+'</div>';
|
|
239
|
+
if(a.input){row+='<div class="text-[11px] text-txt-300 mt-1.5 bg-base-900/60 rounded-md p-2 border border-base-700/40 overflow-x-auto font-mono whitespace-pre max-h-[200px] overflow-y-auto">'+E(a.input)+'</div>'}
|
|
240
|
+
if(a.output){row+='<div class="text-[11px] text-emerald-300/80 mt-1.5 bg-emerald-950/20 rounded-md p-2 border border-emerald-800/30 overflow-x-auto whitespace-pre max-h-[200px] overflow-y-auto">'+E(a.output)+'</div>'}
|
|
241
|
+
if(a.error){row+='<div class="text-[11px] text-red-400 mt-1.5 bg-red-950/20 rounded-md p-2 border border-red-800/30 overflow-x-auto">'+E(a.error)+'</div>'}
|
|
242
|
+
if(a.command){row+='<div class="text-[11px] text-amber-300 mt-1.5 bg-amber-950/20 rounded-md p-2 border border-amber-800/30 overflow-x-auto font-mono whitespace-pre">'+E(a.command)+'</div>'}
|
|
243
|
+
if(a.exitCode!==undefined||a.cost||a.tokensIn||a.tokensOut){
|
|
244
|
+
row+='<div class="flex flex-wrap gap-1.5 mt-1.5">';
|
|
245
|
+
if(a.exitCode!==undefined){row+=chip('exit: '+a.exitCode,'muted')}
|
|
246
|
+
if(a.cost){row+=chip('$'+a.cost.toFixed(4),'gray')}
|
|
247
|
+
if(a.tokensIn||a.tokensOut){row+=chip((a.tokensIn||0)+' in / '+(a.tokensOut||0)+' out','muted')}
|
|
248
|
+
row+='</div>';
|
|
249
|
+
}
|
|
250
|
+
row+='</div>';
|
|
251
|
+
row+='<span class="text-[10px] text-txt-500 shrink-0">'+ts+'</span>';
|
|
252
|
+
row+='</div></div>';
|
|
253
|
+
return row;
|
|
254
|
+
}).join('');
|
|
255
|
+
el.innerHTML=html;
|
|
162
256
|
}
|
|
163
257
|
|
|
164
258
|
function closeDrawer(){
|
package/src/dashboard.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { DASHBOARD_JS_CORE } from "./dashboard-js-core"
|
|
|
6
6
|
import { DASHBOARD_JS_EVENTS } from "./dashboard-js-events"
|
|
7
7
|
import { DASHBOARD_JS_RENDER } from "./dashboard-js-render"
|
|
8
8
|
import { log } from "./log"
|
|
9
|
+
import type { ActivityBuffer, ActivityEntry } from "./activity"
|
|
10
|
+
import type { PluginClient } from "./types"
|
|
9
11
|
|
|
10
12
|
/** Assemble the full dashboard HTML from parts. */
|
|
11
13
|
const DASHBOARD_HTML = DASHBOARD_HEAD + "\n<script>" + DASHBOARD_JS_CORE + DASHBOARD_JS_RENDER + DASHBOARD_JS_EVENTS + "<\/script>\n</body></html>"
|
|
@@ -34,6 +36,7 @@ interface MemberRow {
|
|
|
34
36
|
agent: string
|
|
35
37
|
status: string
|
|
36
38
|
execution_status: string
|
|
39
|
+
session_id: string
|
|
37
40
|
worktree_branch: string | null
|
|
38
41
|
prompt: string | null
|
|
39
42
|
model: string | null
|
|
@@ -81,7 +84,7 @@ function parseDependsOn(value: string | null): string[] {
|
|
|
81
84
|
function buildState(db: Database): { projects: unknown[]; teams: unknown[] } {
|
|
82
85
|
const projects = db.query("SELECT id, name, path, status, time_created, time_updated FROM project ORDER BY time_updated DESC").all() as ProjectRow[]
|
|
83
86
|
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[]
|
|
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 = ?")
|
|
87
|
+
const memberStmt = db.query("SELECT name, agent, status, execution_status, session_id, worktree_branch, prompt, model, plan_approval, time_created, time_updated FROM team_member WHERE team_id = ?")
|
|
85
88
|
const taskStmt = db.query("SELECT id, content, status, priority, assignee, depends_on, time_created, time_updated FROM team_task WHERE team_id = ?")
|
|
86
89
|
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")
|
|
87
90
|
|
|
@@ -91,6 +94,7 @@ function buildState(db: Database): { projects: unknown[]; teams: unknown[] } {
|
|
|
91
94
|
agent: m.agent,
|
|
92
95
|
status: m.status,
|
|
93
96
|
executionStatus: m.execution_status,
|
|
97
|
+
sessionId: m.session_id,
|
|
94
98
|
worktreeBranch: m.worktree_branch,
|
|
95
99
|
prompt: m.prompt,
|
|
96
100
|
model: m.model,
|
|
@@ -163,6 +167,14 @@ export interface DashboardServer {
|
|
|
163
167
|
stop(force?: boolean): void
|
|
164
168
|
}
|
|
165
169
|
|
|
170
|
+
/** Optional dependencies for the dashboard server. */
|
|
171
|
+
export interface DashboardOptions {
|
|
172
|
+
/** In-memory activity buffer for real-time per-session events. */
|
|
173
|
+
activityBuffer?: ActivityBuffer
|
|
174
|
+
/** SDK client for on-demand session message retrieval. */
|
|
175
|
+
client?: PluginClient
|
|
176
|
+
}
|
|
177
|
+
|
|
166
178
|
function sendJson(res: ServerResponse, data: unknown): void {
|
|
167
179
|
res.writeHead(200, {
|
|
168
180
|
"Content-Type": "application/json",
|
|
@@ -171,7 +183,101 @@ function sendJson(res: ServerResponse, data: unknown): void {
|
|
|
171
183
|
res.end(JSON.stringify(data))
|
|
172
184
|
}
|
|
173
185
|
|
|
174
|
-
|
|
186
|
+
/** Parse SDK message parts into ActivityEntry format for the fallback path. */
|
|
187
|
+
export function parseMessageParts(parts: unknown[], msgInfo: unknown): ActivityEntry[] {
|
|
188
|
+
const entries: ActivityEntry[] = []
|
|
189
|
+
const info = (msgInfo ?? {}) as { time?: string; role?: string; tokens?: { input?: number; output?: number } }
|
|
190
|
+
const timestamp = info.time ? new Date(info.time).getTime() : Date.now()
|
|
191
|
+
|
|
192
|
+
for (const part of parts) {
|
|
193
|
+
if (typeof part !== "object" || part === null) continue
|
|
194
|
+
const p = part as {
|
|
195
|
+
type?: string
|
|
196
|
+
tool?: string
|
|
197
|
+
state?: { status?: string; input?: unknown; output?: unknown; error?: string; title?: string }
|
|
198
|
+
text?: string
|
|
199
|
+
path?: string
|
|
200
|
+
content?: string
|
|
201
|
+
diff?: string
|
|
202
|
+
label?: string
|
|
203
|
+
step?: string
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (p.type === "tool" && p.tool) {
|
|
207
|
+
const state = p.state ?? {}
|
|
208
|
+
const inputStr = typeof state.input === "string" ? state.input : state.input != null ? JSON.stringify(state.input, null, 2) : undefined
|
|
209
|
+
const outputStr = typeof state.output === "string" ? state.output : state.output != null ? JSON.stringify(state.output, null, 2) : undefined
|
|
210
|
+
entries.push({
|
|
211
|
+
type: state.status === "completed" ? "tool_result" : "tool_call",
|
|
212
|
+
tool: p.tool,
|
|
213
|
+
title: state.title,
|
|
214
|
+
input: inputStr,
|
|
215
|
+
output: outputStr,
|
|
216
|
+
error: state.error,
|
|
217
|
+
timestamp,
|
|
218
|
+
})
|
|
219
|
+
} else if (p.type === "reasoning" && p.text) {
|
|
220
|
+
entries.push({ type: "reasoning", reasoning: p.text, timestamp })
|
|
221
|
+
} else if (p.type === "file" && (p.path || p.content || p.diff)) {
|
|
222
|
+
entries.push({
|
|
223
|
+
type: "file",
|
|
224
|
+
filePath: p.path,
|
|
225
|
+
fileContent: p.content,
|
|
226
|
+
fileDiff: p.diff,
|
|
227
|
+
timestamp,
|
|
228
|
+
})
|
|
229
|
+
} else if (p.type === "text" && p.text) {
|
|
230
|
+
entries.push({ type: "text", text: p.text, role: info.role, timestamp })
|
|
231
|
+
} else if (p.type === "step-start") {
|
|
232
|
+
entries.push({ type: "step", title: p.label ?? p.step ?? "step", timestamp })
|
|
233
|
+
} else if (p.type === "step-finish") {
|
|
234
|
+
entries.push({ type: "step", title: p.label ?? p.step ?? "step complete", timestamp })
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return entries
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Handle the /api/session/:sessionId/activity endpoint. */
|
|
241
|
+
async function handleActivityRoute(
|
|
242
|
+
sessionId: string,
|
|
243
|
+
options: DashboardOptions | undefined,
|
|
244
|
+
res: ServerResponse,
|
|
245
|
+
): Promise<void> {
|
|
246
|
+
const buffer = options?.activityBuffer
|
|
247
|
+
const client = options?.client
|
|
248
|
+
|
|
249
|
+
const buffered = buffer?.getActivity(sessionId) ?? []
|
|
250
|
+
|
|
251
|
+
let sessionData: unknown = null
|
|
252
|
+
let fallbackActivity: ActivityEntry[] = []
|
|
253
|
+
|
|
254
|
+
if (client) {
|
|
255
|
+
try {
|
|
256
|
+
const [msgResult, getResult] = await Promise.all([
|
|
257
|
+
client.session.messages({ sessionID: sessionId, limit: 100 }),
|
|
258
|
+
client.session.get({ sessionID: sessionId }),
|
|
259
|
+
])
|
|
260
|
+
const messages = msgResult.data ?? []
|
|
261
|
+
for (const msg of messages) {
|
|
262
|
+
const parts = msg.parts ?? []
|
|
263
|
+
fallbackActivity.push(...parseMessageParts(parts, msg.info))
|
|
264
|
+
}
|
|
265
|
+
sessionData = getResult.data ?? null
|
|
266
|
+
} catch { /* best effort — return what we have */ }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const combined = [...buffered, ...fallbackActivity].sort((a, b) => a.timestamp - b.timestamp)
|
|
270
|
+
|
|
271
|
+
sendJson(res, { activity: combined, session: sessionData })
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function handleDashboardRequest(
|
|
275
|
+
db: Database,
|
|
276
|
+
port: number,
|
|
277
|
+
req: IncomingMessage,
|
|
278
|
+
res: ServerResponse,
|
|
279
|
+
options?: DashboardOptions,
|
|
280
|
+
): void {
|
|
175
281
|
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? `localhost:${port}`}`)
|
|
176
282
|
|
|
177
283
|
if (url.pathname === "/api/health") {
|
|
@@ -184,6 +290,18 @@ function handleDashboardRequest(db: Database, port: number, req: IncomingMessage
|
|
|
184
290
|
return
|
|
185
291
|
}
|
|
186
292
|
|
|
293
|
+
const activityMatch = url.pathname.match(/^\/api\/session\/([^/]+)\/activity$/)
|
|
294
|
+
if (activityMatch) {
|
|
295
|
+
const sessionId = decodeURIComponent(activityMatch[1]!)
|
|
296
|
+
handleActivityRoute(sessionId, options, res).catch(() => {
|
|
297
|
+
if (!res.headersSent) {
|
|
298
|
+
res.writeHead(500, { "Content-Type": "application/json" })
|
|
299
|
+
res.end(JSON.stringify({ error: "Failed to fetch activity" }))
|
|
300
|
+
}
|
|
301
|
+
})
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
|
|
187
305
|
if (url.pathname === "/") {
|
|
188
306
|
res.writeHead(200, { "Content-Type": "text/html" })
|
|
189
307
|
res.end(DASHBOARD_HTML)
|
|
@@ -212,13 +330,13 @@ function toDashboardServer(server: Server): DashboardServer {
|
|
|
212
330
|
|
|
213
331
|
/**
|
|
214
332
|
* Start the dashboard HTTP server.
|
|
215
|
-
* Serves a JSON API for team state and the dashboard HTML.
|
|
333
|
+
* Serves a JSON API for team state, session activity, and the dashboard HTML.
|
|
216
334
|
* Singleton: if the port is already in use by another ensemble instance, skips silently.
|
|
217
335
|
* Returns the server instance, or null if skipped.
|
|
218
336
|
*/
|
|
219
|
-
export async function startDashboard(db: Database, port: number): Promise<DashboardServer | null> {
|
|
337
|
+
export async function startDashboard(db: Database, port: number, options?: DashboardOptions): Promise<DashboardServer | null> {
|
|
220
338
|
return new Promise((resolve) => {
|
|
221
|
-
const server = createServer((req, res) => handleDashboardRequest(db, port, req, res))
|
|
339
|
+
const server = createServer((req, res) => handleDashboardRequest(db, port, req, res, options))
|
|
222
340
|
|
|
223
341
|
server.once("error", async (err: NodeJS.ErrnoException) => {
|
|
224
342
|
if (err.code === "EADDRINUSE") {
|