@hueyexe/opencode-ensemble 0.15.2 → 0.16.1

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.15.2",
3
+ "version": "0.16.1",
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.15.1",
49
- "@opencode-ai/sdk": "^1.15.1"
48
+ "@opencode-ai/plugin": "^1.17.18",
49
+ "@opencode-ai/sdk": "^1.17.18"
50
50
  }
51
51
  }
@@ -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)),
@@ -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>
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'):'';
6
6
  const D=ms=>{const s=Math.floor(Math.abs(ms)/1000);return s<60?s+'s':s<3600?Math.floor(s/60)+'m':Math.floor(s/3600)+'h'};
@@ -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);
@@ -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
- function handleDashboardRequest(db: Database, port: number, req: IncomingMessage, res: ServerResponse): void {
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") {