multiagents 0.1.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/.mcp.json +12 -0
- package/README.md +184 -0
- package/adapters/base-adapter.ts +1493 -0
- package/adapters/claude-adapter.ts +66 -0
- package/adapters/codex-adapter.ts +135 -0
- package/adapters/gemini-adapter.ts +129 -0
- package/broker.ts +1263 -0
- package/cli/commands.ts +194 -0
- package/cli/dashboard.ts +988 -0
- package/cli/session.ts +278 -0
- package/cli/setup.ts +257 -0
- package/cli.ts +17 -0
- package/index.ts +41 -0
- package/noop-mcp.ts +63 -0
- package/orchestrator/guardrails.ts +243 -0
- package/orchestrator/launcher.ts +433 -0
- package/orchestrator/monitor.ts +285 -0
- package/orchestrator/orchestrator-server.ts +1000 -0
- package/orchestrator/progress.ts +214 -0
- package/orchestrator/recovery.ts +176 -0
- package/orchestrator/session-control.ts +343 -0
- package/package.json +70 -0
- package/scripts/postinstall.ts +84 -0
- package/scripts/version.ts +62 -0
- package/server.ts +52 -0
- package/shared/broker-client.ts +243 -0
- package/shared/constants.ts +148 -0
- package/shared/summarize.ts +97 -0
- package/shared/types.ts +419 -0
- package/shared/utils.ts +121 -0
- package/tsconfig.json +29 -0
package/cli/dashboard.ts
ADDED
|
@@ -0,0 +1,988 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// multiagents — TUI Dashboard (ANSI escape codes, no dependencies)
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Tab-based dashboard with:
|
|
5
|
+
// [1] Agents — team status with task states
|
|
6
|
+
// [2] Messages — auto-scrolling message log with filtering
|
|
7
|
+
// [3] Guardrails — interactive limit adjustment
|
|
8
|
+
// [4] Files — file locks and ownership
|
|
9
|
+
// ============================================================================
|
|
10
|
+
|
|
11
|
+
import { DEFAULT_BROKER_PORT, BROKER_HOSTNAME, SESSION_FILE, DASHBOARD_REFRESH } from "../shared/constants.ts";
|
|
12
|
+
import { BrokerClient, type PlanState, type PlanItem } from "../shared/broker-client.ts";
|
|
13
|
+
import { formatDuration, formatTime, truncate } from "../shared/utils.ts";
|
|
14
|
+
import type { Session, Slot, Peer, Message, FileLock, GuardrailState, SessionFile } from "../shared/types.ts";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
|
|
18
|
+
const BROKER_PORT = parseInt(process.env.MULTIAGENTS_PORT ?? String(DEFAULT_BROKER_PORT), 10);
|
|
19
|
+
const BROKER_URL = `http://${BROKER_HOSTNAME}:${BROKER_PORT}`;
|
|
20
|
+
|
|
21
|
+
// --- ANSI helpers ---
|
|
22
|
+
const ESC = "\x1b";
|
|
23
|
+
const CLEAR = `${ESC}[2J`;
|
|
24
|
+
const HOME = `${ESC}[H`;
|
|
25
|
+
const BOLD = `${ESC}[1m`;
|
|
26
|
+
const RESET = `${ESC}[0m`;
|
|
27
|
+
const GREEN = `${ESC}[32m`;
|
|
28
|
+
const RED = `${ESC}[31m`;
|
|
29
|
+
const YELLOW = `${ESC}[33m`;
|
|
30
|
+
const CYAN = `${ESC}[36m`;
|
|
31
|
+
const BLUE = `${ESC}[34m`;
|
|
32
|
+
const MAGENTA = `${ESC}[35m`;
|
|
33
|
+
const WHITE = `${ESC}[37m`;
|
|
34
|
+
const DIM = `${ESC}[90m`;
|
|
35
|
+
const BG_BLUE = `${ESC}[44m`;
|
|
36
|
+
const BG_RESET = `${ESC}[49m`;
|
|
37
|
+
const UNDERLINE = `${ESC}[4m`;
|
|
38
|
+
const HIDE_CURSOR = `${ESC}[?25l`;
|
|
39
|
+
const SHOW_CURSOR = `${ESC}[?25h`;
|
|
40
|
+
const INVERSE = `${ESC}[7m`;
|
|
41
|
+
|
|
42
|
+
function colorStatus(status: string): string {
|
|
43
|
+
switch (status) {
|
|
44
|
+
case "connected": case "active": case "ok": return `${GREEN}${status}${RESET}`;
|
|
45
|
+
case "disconnected": case "paused": case "warning": return `${YELLOW}${status}${RESET}`;
|
|
46
|
+
case "archived": case "triggered": case "error": return `${RED}${status}${RESET}`;
|
|
47
|
+
default: return status;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function colorTaskState(state: string): string {
|
|
52
|
+
switch (state) {
|
|
53
|
+
case "idle": return `${DIM}idle${RESET}`;
|
|
54
|
+
case "working": return `${CYAN}working${RESET}`;
|
|
55
|
+
case "done_pending_review": return `${YELLOW}done→review${RESET}`;
|
|
56
|
+
case "addressing_feedback": return `${MAGENTA}fixing${RESET}`;
|
|
57
|
+
case "approved": return `${GREEN}approved${RESET}`;
|
|
58
|
+
case "released": return `${DIM}released${RESET}`;
|
|
59
|
+
default: return state;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function colorMsgType(type: string): string {
|
|
64
|
+
switch (type) {
|
|
65
|
+
case "chat": return `${WHITE}chat${RESET}`;
|
|
66
|
+
case "task_complete": return `${GREEN}done${RESET}`;
|
|
67
|
+
case "review_request": return `${CYAN}review${RESET}`;
|
|
68
|
+
case "feedback": return `${YELLOW}feedback${RESET}`;
|
|
69
|
+
case "approval": return `${GREEN}approve${RESET}`;
|
|
70
|
+
case "release": return `${MAGENTA}release${RESET}`;
|
|
71
|
+
case "team_change": return `${BLUE}team${RESET}`;
|
|
72
|
+
case "system": return `${DIM}system${RESET}`;
|
|
73
|
+
case "broadcast": return `${CYAN}bcast${RESET}`;
|
|
74
|
+
default: return `${DIM}${type}${RESET}`;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function progressBar(percent: number, width: number = 20): string {
|
|
79
|
+
const clamped = Math.max(0, Math.min(1, percent));
|
|
80
|
+
const filled = Math.round(clamped * width);
|
|
81
|
+
const empty = width - filled;
|
|
82
|
+
const color = clamped >= 0.9 ? RED : clamped >= 0.7 ? YELLOW : GREEN;
|
|
83
|
+
return `${color}${"█".repeat(filled)}${DIM}${"░".repeat(empty)}${RESET}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function padRight(str: string, len: number): string {
|
|
87
|
+
const visible = str.replace(/\x1b\[[0-9;]*m/g, "");
|
|
88
|
+
const pad = Math.max(0, len - visible.length);
|
|
89
|
+
return str + " ".repeat(pad);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function visibleLength(str: string): number {
|
|
93
|
+
return str.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readLocalSession(): SessionFile | null {
|
|
97
|
+
try {
|
|
98
|
+
const text = fs.readFileSync(path.resolve(process.cwd(), SESSION_FILE), "utf-8");
|
|
99
|
+
return JSON.parse(text) as SessionFile;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- Resume helper ---
|
|
106
|
+
/**
|
|
107
|
+
* Properly resume a session after a guardrail limit is adjusted.
|
|
108
|
+
* Unpauses the session, unpauses all slots, and releases held messages.
|
|
109
|
+
*/
|
|
110
|
+
async function resumeSessionAfterAdjustment(
|
|
111
|
+
client: BrokerClient,
|
|
112
|
+
sessionId: string,
|
|
113
|
+
state: DashboardState,
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
await client.updateSession({
|
|
116
|
+
id: sessionId,
|
|
117
|
+
status: "active",
|
|
118
|
+
pause_reason: null,
|
|
119
|
+
paused_at: null,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
for (const slot of state.slots) {
|
|
123
|
+
if (slot.paused) {
|
|
124
|
+
await client.updateSlot({ id: slot.id, paused: false, paused_at: null });
|
|
125
|
+
// Release any messages held during the pause
|
|
126
|
+
await client.releaseHeldMessages(sessionId, slot.id).catch(() => {});
|
|
127
|
+
// Notify the agent it can resume
|
|
128
|
+
if (slot.peer_id) {
|
|
129
|
+
await client.sendMessage({
|
|
130
|
+
from_id: "orchestrator",
|
|
131
|
+
to_id: slot.peer_id,
|
|
132
|
+
text: JSON.stringify({ action: "resume", reason: "Guardrail limit adjusted from dashboard" }),
|
|
133
|
+
msg_type: "control",
|
|
134
|
+
session_id: sessionId,
|
|
135
|
+
}).catch(() => {});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// --- Tabs ---
|
|
142
|
+
type Tab = "agents" | "messages" | "stats" | "plan" | "files";
|
|
143
|
+
const TABS: { key: string; id: Tab; label: string }[] = [
|
|
144
|
+
{ key: "1", id: "agents", label: "Agents" },
|
|
145
|
+
{ key: "2", id: "messages", label: "Messages" },
|
|
146
|
+
{ key: "3", id: "stats", label: "Stats" },
|
|
147
|
+
{ key: "4", id: "plan", label: "Plan" },
|
|
148
|
+
{ key: "5", id: "files", label: "Files" },
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
// --- Dashboard state ---
|
|
152
|
+
interface DashboardState {
|
|
153
|
+
session: Session | null;
|
|
154
|
+
slots: Slot[];
|
|
155
|
+
allPeers: Peer[];
|
|
156
|
+
messages: Message[];
|
|
157
|
+
guardrails: GuardrailState[];
|
|
158
|
+
fileLocks: FileLock[];
|
|
159
|
+
planState: PlanState | null;
|
|
160
|
+
brokerAlive: boolean;
|
|
161
|
+
error: string | null;
|
|
162
|
+
toast: { text: string; expires: number } | null;
|
|
163
|
+
activeTab: Tab;
|
|
164
|
+
scrollOffset: number;
|
|
165
|
+
selectedRow: number;
|
|
166
|
+
autoScroll: boolean;
|
|
167
|
+
lastMessageCount: number;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function dashboard(sessionId?: string): Promise<void> {
|
|
171
|
+
const sid = sessionId ?? readLocalSession()?.session_id ?? null;
|
|
172
|
+
const client = new BrokerClient(BROKER_URL);
|
|
173
|
+
|
|
174
|
+
if (!(await client.isAlive())) {
|
|
175
|
+
console.error("Broker is not running. Start it with: bun broker.ts");
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const state: DashboardState = {
|
|
180
|
+
session: null,
|
|
181
|
+
slots: [],
|
|
182
|
+
allPeers: [],
|
|
183
|
+
messages: [],
|
|
184
|
+
guardrails: [],
|
|
185
|
+
fileLocks: [],
|
|
186
|
+
planState: null,
|
|
187
|
+
brokerAlive: true,
|
|
188
|
+
error: null,
|
|
189
|
+
toast: null,
|
|
190
|
+
activeTab: "agents",
|
|
191
|
+
scrollOffset: 0,
|
|
192
|
+
selectedRow: 0,
|
|
193
|
+
autoScroll: true,
|
|
194
|
+
lastMessageCount: 0,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
if (process.stdin.isTTY) {
|
|
198
|
+
process.stdin.setRawMode(true);
|
|
199
|
+
}
|
|
200
|
+
process.stdin.resume();
|
|
201
|
+
process.stdin.setEncoding("utf-8");
|
|
202
|
+
process.stdout.write(HIDE_CURSOR);
|
|
203
|
+
|
|
204
|
+
let running = true;
|
|
205
|
+
|
|
206
|
+
function showToast(text: string, durationMs: number = 3000): void {
|
|
207
|
+
state.toast = { text, expires: Date.now() + durationMs };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Keyboard handler
|
|
211
|
+
process.stdin.on("data", async (key: string) => {
|
|
212
|
+
// Clear expired toast
|
|
213
|
+
if (state.toast && Date.now() > state.toast.expires) {
|
|
214
|
+
state.toast = null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Tab switching: 1-4
|
|
218
|
+
for (const tab of TABS) {
|
|
219
|
+
if (key === tab.key) {
|
|
220
|
+
state.activeTab = tab.id;
|
|
221
|
+
state.scrollOffset = 0;
|
|
222
|
+
state.selectedRow = 0;
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Tab cycling: Tab key
|
|
228
|
+
if (key === "\t") {
|
|
229
|
+
const idx = TABS.findIndex((t) => t.id === state.activeTab);
|
|
230
|
+
state.activeTab = TABS[(idx + 1) % TABS.length].id;
|
|
231
|
+
state.scrollOffset = 0;
|
|
232
|
+
state.selectedRow = 0;
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
switch (key) {
|
|
237
|
+
case "q":
|
|
238
|
+
case "\x03": // Ctrl+C
|
|
239
|
+
running = false;
|
|
240
|
+
cleanup();
|
|
241
|
+
break;
|
|
242
|
+
|
|
243
|
+
case "p": {
|
|
244
|
+
showToast("Pausing all agents...");
|
|
245
|
+
try {
|
|
246
|
+
if (sid) {
|
|
247
|
+
await client.updateSession({ id: sid, status: "paused", pause_reason: "Paused from dashboard", paused_at: Date.now() });
|
|
248
|
+
}
|
|
249
|
+
for (const s of state.slots) {
|
|
250
|
+
await client.updateSlot({ id: s.id, paused: true, paused_at: Date.now() });
|
|
251
|
+
}
|
|
252
|
+
showToast("All agents paused");
|
|
253
|
+
} catch (e) {
|
|
254
|
+
showToast(`Pause failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
255
|
+
}
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
case "r": {
|
|
260
|
+
showToast("Resuming all agents...");
|
|
261
|
+
try {
|
|
262
|
+
if (sid) {
|
|
263
|
+
await client.updateSession({ id: sid, status: "active", pause_reason: null, paused_at: null });
|
|
264
|
+
}
|
|
265
|
+
for (const s of state.slots) {
|
|
266
|
+
await client.updateSlot({ id: s.id, paused: false, paused_at: null });
|
|
267
|
+
}
|
|
268
|
+
showToast("All agents resumed");
|
|
269
|
+
} catch (e) {
|
|
270
|
+
showToast(`Resume failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
271
|
+
}
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Scroll / select
|
|
276
|
+
case "j":
|
|
277
|
+
case "\x1b[B": // Down arrow
|
|
278
|
+
if (state.activeTab === "messages") {
|
|
279
|
+
state.autoScroll = false;
|
|
280
|
+
const maxScroll = Math.max(0, state.messages.length - msgVisibleRows());
|
|
281
|
+
state.scrollOffset = Math.min(state.scrollOffset + 1, maxScroll);
|
|
282
|
+
// Re-enable auto-scroll if at bottom
|
|
283
|
+
if (state.scrollOffset >= maxScroll) state.autoScroll = true;
|
|
284
|
+
} else if (state.activeTab === "stats") {
|
|
285
|
+
state.selectedRow = Math.min(state.selectedRow + 1, state.guardrails.length - 1);
|
|
286
|
+
} else if (state.activeTab === "agents") {
|
|
287
|
+
state.selectedRow = Math.min(state.selectedRow + 1, state.slots.length - 1);
|
|
288
|
+
}
|
|
289
|
+
break;
|
|
290
|
+
|
|
291
|
+
case "k":
|
|
292
|
+
case "\x1b[A": // Up arrow
|
|
293
|
+
if (state.activeTab === "messages") {
|
|
294
|
+
state.autoScroll = false;
|
|
295
|
+
state.scrollOffset = Math.max(0, state.scrollOffset - 1);
|
|
296
|
+
} else if (state.activeTab === "stats" || state.activeTab === "agents") {
|
|
297
|
+
state.selectedRow = Math.max(0, state.selectedRow - 1);
|
|
298
|
+
}
|
|
299
|
+
break;
|
|
300
|
+
|
|
301
|
+
case "G": // Jump to bottom (messages)
|
|
302
|
+
if (state.activeTab === "messages") {
|
|
303
|
+
state.autoScroll = true;
|
|
304
|
+
state.scrollOffset = Math.max(0, state.messages.length - msgVisibleRows());
|
|
305
|
+
}
|
|
306
|
+
break;
|
|
307
|
+
|
|
308
|
+
case "g": // Jump to top (messages)
|
|
309
|
+
if (state.activeTab === "messages") {
|
|
310
|
+
state.autoScroll = false;
|
|
311
|
+
state.scrollOffset = 0;
|
|
312
|
+
}
|
|
313
|
+
break;
|
|
314
|
+
|
|
315
|
+
// Guardrail controls: + to increase selected guardrail
|
|
316
|
+
case "+":
|
|
317
|
+
case "=": {
|
|
318
|
+
if (state.activeTab === "stats" && sid) {
|
|
319
|
+
const g = state.guardrails[state.selectedRow];
|
|
320
|
+
if (g && g.adjustable && g.suggested_increases?.length > 0) {
|
|
321
|
+
const nextValue = g.suggested_increases.find((v) => v > g.current_value)
|
|
322
|
+
?? g.suggested_increases[g.suggested_increases.length - 1];
|
|
323
|
+
try {
|
|
324
|
+
await client.updateGuardrail({
|
|
325
|
+
session_id: sid,
|
|
326
|
+
guardrail_id: g.id,
|
|
327
|
+
new_value: nextValue,
|
|
328
|
+
changed_by: "dashboard",
|
|
329
|
+
reason: "Increased from dashboard",
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// Auto-resume if session was paused by a guardrail
|
|
333
|
+
const wasPaused = state.session?.status === "paused"
|
|
334
|
+
&& state.session?.pause_reason?.includes("Guardrail");
|
|
335
|
+
if (wasPaused) {
|
|
336
|
+
await resumeSessionAfterAdjustment(client, sid, state);
|
|
337
|
+
showToast(`${g.label}: ${g.current_value} → ${nextValue} ${g.unit} — session auto-resumed`);
|
|
338
|
+
} else {
|
|
339
|
+
showToast(`${g.label}: ${g.current_value} → ${nextValue} ${g.unit}`);
|
|
340
|
+
}
|
|
341
|
+
} catch (e) {
|
|
342
|
+
showToast(`Failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
case "-": {
|
|
350
|
+
if (state.activeTab === "stats" && sid) {
|
|
351
|
+
const g = state.guardrails[state.selectedRow];
|
|
352
|
+
if (g && g.adjustable) {
|
|
353
|
+
const prevValue = [...g.suggested_increases].reverse().find((v) => v < g.current_value)
|
|
354
|
+
?? Math.max(1, Math.floor(g.current_value / 2));
|
|
355
|
+
try {
|
|
356
|
+
await client.updateGuardrail({
|
|
357
|
+
session_id: sid,
|
|
358
|
+
guardrail_id: g.id,
|
|
359
|
+
new_value: prevValue,
|
|
360
|
+
changed_by: "dashboard",
|
|
361
|
+
reason: "Decreased from dashboard",
|
|
362
|
+
});
|
|
363
|
+
showToast(`${g.label}: ${g.current_value} → ${prevValue} ${g.unit}`);
|
|
364
|
+
} catch (e) {
|
|
365
|
+
showToast(`Failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
function msgVisibleRows(): number {
|
|
375
|
+
return Math.max(5, (process.stdout.rows || 40) - 8);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function cleanup(): void {
|
|
379
|
+
process.stdout.write(SHOW_CURSOR);
|
|
380
|
+
process.stdout.write(CLEAR + HOME);
|
|
381
|
+
if (process.stdin.isTTY) {
|
|
382
|
+
process.stdin.setRawMode(false);
|
|
383
|
+
}
|
|
384
|
+
process.stdin.pause();
|
|
385
|
+
process.exit(0);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
let prevSessionStatus = "";
|
|
389
|
+
|
|
390
|
+
while (running) {
|
|
391
|
+
await fetchState(client, sid, state);
|
|
392
|
+
|
|
393
|
+
// Auto-scroll messages when new ones arrive
|
|
394
|
+
if (state.autoScroll && state.messages.length > state.lastMessageCount) {
|
|
395
|
+
state.scrollOffset = Math.max(0, state.messages.length - msgVisibleRows());
|
|
396
|
+
}
|
|
397
|
+
state.lastMessageCount = state.messages.length;
|
|
398
|
+
|
|
399
|
+
// Auto-switch to guardrails tab when session gets paused by a guardrail
|
|
400
|
+
const curStatus = state.session?.status ?? "";
|
|
401
|
+
if (curStatus === "paused" && prevSessionStatus === "active" && state.session?.pause_reason?.includes("Guardrail")) {
|
|
402
|
+
state.activeTab = "stats";
|
|
403
|
+
state.selectedRow = 0;
|
|
404
|
+
// Select the triggered guardrail
|
|
405
|
+
const triggeredIdx = state.guardrails.findIndex((g) => g.usage?.status === "triggered");
|
|
406
|
+
if (triggeredIdx >= 0) state.selectedRow = triggeredIdx;
|
|
407
|
+
showToast("Session paused by guardrail — press + to increase limit and auto-resume", 10000);
|
|
408
|
+
}
|
|
409
|
+
prevSessionStatus = curStatus;
|
|
410
|
+
|
|
411
|
+
// Clear expired toast
|
|
412
|
+
if (state.toast && Date.now() > state.toast.expires) {
|
|
413
|
+
state.toast = null;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
render(state, sid);
|
|
417
|
+
await Bun.sleep(DASHBOARD_REFRESH);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function fetchState(client: BrokerClient, sessionId: string | null, state: DashboardState): Promise<void> {
|
|
422
|
+
try {
|
|
423
|
+
state.brokerAlive = await client.isAlive();
|
|
424
|
+
if (!state.brokerAlive) {
|
|
425
|
+
state.error = "Broker is not responding";
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const allPeers = await client.listPeers({ scope: "machine" }).catch(() => [] as Peer[]);
|
|
430
|
+
state.allPeers = allPeers;
|
|
431
|
+
|
|
432
|
+
if (sessionId) {
|
|
433
|
+
const [session, slots, messages, guardrails, fileLocks, planState] = await Promise.all([
|
|
434
|
+
client.getSession(sessionId).catch(() => null),
|
|
435
|
+
client.listSlots(sessionId).catch(() => [] as Slot[]),
|
|
436
|
+
client.getMessageLog(sessionId, { limit: 100 }).catch(() => [] as Message[]),
|
|
437
|
+
client.getGuardrails(sessionId).catch(() => [] as GuardrailState[]),
|
|
438
|
+
client.listFileLocks(sessionId).catch(() => [] as FileLock[]),
|
|
439
|
+
client.getPlan(sessionId).catch(() => null as PlanState | null),
|
|
440
|
+
]);
|
|
441
|
+
|
|
442
|
+
state.session = session;
|
|
443
|
+
state.slots = slots;
|
|
444
|
+
state.messages = messages;
|
|
445
|
+
state.guardrails = guardrails;
|
|
446
|
+
state.fileLocks = fileLocks;
|
|
447
|
+
state.planState = planState;
|
|
448
|
+
} else {
|
|
449
|
+
state.session = null;
|
|
450
|
+
state.slots = [];
|
|
451
|
+
state.messages = [];
|
|
452
|
+
state.guardrails = [];
|
|
453
|
+
state.fileLocks = [];
|
|
454
|
+
state.planState = null;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
state.error = null;
|
|
458
|
+
} catch (e) {
|
|
459
|
+
state.error = e instanceof Error ? e.message : String(e);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// --- Slot status resolution (shared between tabs) ---
|
|
464
|
+
function resolveSlots(state: DashboardState, sid: string | null): Map<number, { status: string; peer: Peer | null }> {
|
|
465
|
+
const peerById = new Map(state.allPeers.map((p) => [p.id, p]));
|
|
466
|
+
const sessionPeers = state.allPeers.filter((p) => p.session_id === sid);
|
|
467
|
+
const matchedPeerIds = new Set<string>();
|
|
468
|
+
const result = new Map<number, { status: string; peer: Peer | null }>();
|
|
469
|
+
|
|
470
|
+
for (const slot of state.slots) {
|
|
471
|
+
if (slot.peer_id && peerById.has(slot.peer_id)) {
|
|
472
|
+
matchedPeerIds.add(slot.peer_id);
|
|
473
|
+
result.set(slot.id, { status: "connected", peer: peerById.get(slot.peer_id)! });
|
|
474
|
+
} else {
|
|
475
|
+
const slotMatch = sessionPeers.find(
|
|
476
|
+
(p) => !matchedPeerIds.has(p.id) && p.slot_id === slot.id,
|
|
477
|
+
);
|
|
478
|
+
if (slotMatch) {
|
|
479
|
+
matchedPeerIds.add(slotMatch.id);
|
|
480
|
+
result.set(slot.id, { status: "connected", peer: slotMatch });
|
|
481
|
+
} else {
|
|
482
|
+
result.set(slot.id, { status: slot.status, peer: null });
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return result;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// --- Rendering ---
|
|
490
|
+
|
|
491
|
+
function render(state: DashboardState, sid: string | null): void {
|
|
492
|
+
const cols = process.stdout.columns || 80;
|
|
493
|
+
const rows = process.stdout.rows || 40;
|
|
494
|
+
const lines: string[] = [];
|
|
495
|
+
|
|
496
|
+
// === HEADER (2 lines) ===
|
|
497
|
+
const brokerDot = state.brokerAlive ? `${GREEN}●${RESET}` : `${RED}●${RESET}`;
|
|
498
|
+
let headerRight = "";
|
|
499
|
+
if (state.session) {
|
|
500
|
+
const connectedCount = state.slots.filter((s) => {
|
|
501
|
+
const r = resolveSlots(state, sid).get(s.id);
|
|
502
|
+
return r?.status === "connected";
|
|
503
|
+
}).length;
|
|
504
|
+
const sessionStatus = colorStatus(state.session.status);
|
|
505
|
+
const uptime = formatDuration(Date.now() - state.session.created_at);
|
|
506
|
+
headerRight = `${connectedCount}/${state.slots.length} agents ${sessionStatus} ${DIM}${uptime}${RESET}`;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const title = `${brokerDot} ${BOLD}${CYAN}${state.session?.name ?? sid ?? "multiagents"}${RESET}`;
|
|
510
|
+
lines.push(` ${title} ${headerRight}`);
|
|
511
|
+
|
|
512
|
+
// === TAB BAR ===
|
|
513
|
+
let tabBar = " ";
|
|
514
|
+
for (const tab of TABS) {
|
|
515
|
+
const isActive = tab.id === state.activeTab;
|
|
516
|
+
const badge = getBadge(tab.id, state);
|
|
517
|
+
if (isActive) {
|
|
518
|
+
tabBar += `${INVERSE}${BOLD} ${tab.key}:${tab.label}${badge} ${RESET} `;
|
|
519
|
+
} else {
|
|
520
|
+
tabBar += `${DIM}${tab.key}:${tab.label}${badge}${RESET} `;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
lines.push(tabBar);
|
|
524
|
+
lines.push(`${DIM}${"─".repeat(cols)}${RESET}`);
|
|
525
|
+
|
|
526
|
+
// === TOAST ===
|
|
527
|
+
if (state.toast) {
|
|
528
|
+
lines.push(` ${YELLOW}▸ ${state.toast.text}${RESET}`);
|
|
529
|
+
} else if (state.error) {
|
|
530
|
+
lines.push(` ${RED}▸ ${state.error}${RESET}`);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// === TAB CONTENT ===
|
|
534
|
+
const contentRows = rows - lines.length - 2; // Reserve 2 for bottom bar
|
|
535
|
+
const contentLines: string[] = [];
|
|
536
|
+
|
|
537
|
+
switch (state.activeTab) {
|
|
538
|
+
case "agents":
|
|
539
|
+
renderAgentsTab(state, sid, cols, contentRows, contentLines);
|
|
540
|
+
break;
|
|
541
|
+
case "messages":
|
|
542
|
+
renderMessagesTab(state, cols, contentRows, contentLines);
|
|
543
|
+
break;
|
|
544
|
+
case "stats":
|
|
545
|
+
renderStatsTab(state, cols, contentRows, contentLines);
|
|
546
|
+
break;
|
|
547
|
+
case "plan":
|
|
548
|
+
renderPlanTab(state, cols, contentRows, contentLines);
|
|
549
|
+
break;
|
|
550
|
+
case "files":
|
|
551
|
+
renderFilesTab(state, cols, contentRows, contentLines);
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Pad content to fill screen
|
|
556
|
+
while (contentLines.length < contentRows) contentLines.push("");
|
|
557
|
+
lines.push(...contentLines.slice(0, contentRows));
|
|
558
|
+
|
|
559
|
+
// === BOTTOM BAR ===
|
|
560
|
+
lines.push(`${DIM}${"─".repeat(cols)}${RESET}`);
|
|
561
|
+
lines.push(getControlsLine(state, sid));
|
|
562
|
+
|
|
563
|
+
process.stdout.write(CLEAR + HOME + lines.join("\n"));
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function getBadge(tab: Tab, state: DashboardState): string {
|
|
567
|
+
switch (tab) {
|
|
568
|
+
case "agents": {
|
|
569
|
+
const connected = state.slots.filter((s) => s.status === "connected").length;
|
|
570
|
+
return connected > 0 ? ` ${GREEN}${connected}${RESET}` : "";
|
|
571
|
+
}
|
|
572
|
+
case "messages":
|
|
573
|
+
return state.messages.length > 0 ? ` ${CYAN}${state.messages.length}${RESET}` : "";
|
|
574
|
+
case "stats": {
|
|
575
|
+
const enforced = state.guardrails.filter((g) => g.action !== "monitor");
|
|
576
|
+
const warnings = enforced.filter((g) => g.usage?.status !== "ok").length;
|
|
577
|
+
return warnings > 0 ? ` ${YELLOW}${warnings}!${RESET}` : "";
|
|
578
|
+
}
|
|
579
|
+
case "plan": {
|
|
580
|
+
const ps = state.planState;
|
|
581
|
+
if (ps?.plan && ps.items.length > 0) {
|
|
582
|
+
return ` ${CYAN}${ps.completion}%${RESET}`;
|
|
583
|
+
}
|
|
584
|
+
return "";
|
|
585
|
+
}
|
|
586
|
+
case "files":
|
|
587
|
+
return state.fileLocks.length > 0 ? ` ${DIM}${state.fileLocks.length}${RESET}` : "";
|
|
588
|
+
default:
|
|
589
|
+
return "";
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function getControlsLine(state: DashboardState, sid: string | null): string {
|
|
594
|
+
const common = `${DIM}q${RESET} quit ${DIM}Tab${RESET} switch ${DIM}1-5${RESET} jump`;
|
|
595
|
+
|
|
596
|
+
switch (state.activeTab) {
|
|
597
|
+
case "agents":
|
|
598
|
+
return ` ${common} ${DIM}j/k${RESET} select ${DIM}p${RESET} pause ${DIM}r${RESET} resume`;
|
|
599
|
+
case "messages":
|
|
600
|
+
return ` ${common} ${DIM}j/k${RESET} scroll ${DIM}g/G${RESET} top/bottom`;
|
|
601
|
+
case "stats":
|
|
602
|
+
return ` ${common} ${DIM}j/k${RESET} select guardrail ${DIM}+/-${RESET} adjust`;
|
|
603
|
+
case "plan":
|
|
604
|
+
return ` ${common}`;
|
|
605
|
+
case "files":
|
|
606
|
+
return ` ${common} ${DIM}j/k${RESET} scroll`;
|
|
607
|
+
default:
|
|
608
|
+
return ` ${common}`;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// === TAB: AGENTS ===
|
|
613
|
+
function renderAgentsTab(state: DashboardState, sid: string | null, cols: number, maxRows: number, lines: string[]): void {
|
|
614
|
+
if (state.slots.length === 0 && state.allPeers.length === 0) {
|
|
615
|
+
lines.push("");
|
|
616
|
+
lines.push(`${DIM} No agents connected${RESET}`);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const resolved = resolveSlots(state, sid);
|
|
621
|
+
|
|
622
|
+
if (state.slots.length > 0) {
|
|
623
|
+
// Header
|
|
624
|
+
const hdrParts = [
|
|
625
|
+
" ",
|
|
626
|
+
"Name".padEnd(18),
|
|
627
|
+
"Type".padEnd(8),
|
|
628
|
+
"Role".padEnd(18),
|
|
629
|
+
"Conn".padEnd(14),
|
|
630
|
+
"Task".padEnd(14),
|
|
631
|
+
];
|
|
632
|
+
lines.push(`${DIM}${hdrParts.join("")}${RESET}`);
|
|
633
|
+
|
|
634
|
+
for (let i = 0; i < state.slots.length; i++) {
|
|
635
|
+
const slot = state.slots[i];
|
|
636
|
+
const r = resolved.get(slot.id) ?? { status: slot.status, peer: null };
|
|
637
|
+
const isSelected = i === state.selectedRow;
|
|
638
|
+
const prefix = isSelected ? `${CYAN}▸${RESET}` : " ";
|
|
639
|
+
|
|
640
|
+
const name = truncate(slot.display_name ?? "—", 16);
|
|
641
|
+
const type = slot.agent_type.padEnd(8);
|
|
642
|
+
const role = truncate(slot.role ?? "—", 16);
|
|
643
|
+
const connStatus = colorStatus(r.status);
|
|
644
|
+
const taskState = colorTaskState(slot.task_state ?? "idle");
|
|
645
|
+
|
|
646
|
+
let line = `${prefix}${name.padEnd(18)}${type}${role.padEnd(18)}${padRight(connStatus, 14)}${taskState}`;
|
|
647
|
+
|
|
648
|
+
// Show summary for selected agent
|
|
649
|
+
if (isSelected && r.peer?.summary) {
|
|
650
|
+
lines.push(line);
|
|
651
|
+
lines.push(` ${DIM}└─ ${truncate(r.peer.summary, cols - 6)}${RESET}`);
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
lines.push(line);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Summary stats
|
|
659
|
+
const connected = [...resolved.values()].filter((r) => r.status === "connected").length;
|
|
660
|
+
const done = state.slots.filter((s) => s.task_state === "done_pending_review" || s.task_state === "approved").length;
|
|
661
|
+
const working = state.slots.filter((s) => s.task_state === "idle" || s.task_state === "working" || s.task_state === "addressing_feedback").length;
|
|
662
|
+
lines.push("");
|
|
663
|
+
lines.push(` ${GREEN}●${RESET} ${connected} connected ${CYAN}●${RESET} ${working} working ${YELLOW}●${RESET} ${done} done/approved`);
|
|
664
|
+
} else {
|
|
665
|
+
// Raw peers mode
|
|
666
|
+
lines.push(`${BOLD} Peers (${state.allPeers.length})${RESET}`);
|
|
667
|
+
lines.push(`${DIM} ${"ID".padEnd(14)}${"Type".padEnd(10)}${"Summary".padEnd(40)}${RESET}`);
|
|
668
|
+
|
|
669
|
+
for (const peer of state.allPeers) {
|
|
670
|
+
const id = truncate(peer.id, 12);
|
|
671
|
+
const agentType = (peer.agent_type ?? "?").padEnd(10);
|
|
672
|
+
const summary = truncate(peer.summary ?? "—", 38);
|
|
673
|
+
lines.push(` ${id.padEnd(14)}${agentType}${summary}`);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// === TAB: MESSAGES ===
|
|
679
|
+
function renderMessagesTab(state: DashboardState, cols: number, maxRows: number, lines: string[]): void {
|
|
680
|
+
if (state.messages.length === 0) {
|
|
681
|
+
lines.push("");
|
|
682
|
+
lines.push(`${DIM} No messages yet — waiting for agent activity${RESET}`);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const slotMap = new Map(state.slots.map((s) => [s.id, s]));
|
|
687
|
+
const visibleCount = maxRows - 2;
|
|
688
|
+
|
|
689
|
+
const maxScroll = Math.max(0, state.messages.length - visibleCount);
|
|
690
|
+
if (state.scrollOffset > maxScroll) state.scrollOffset = maxScroll;
|
|
691
|
+
|
|
692
|
+
const visible = state.messages.slice(state.scrollOffset, state.scrollOffset + visibleCount);
|
|
693
|
+
|
|
694
|
+
for (const m of visible) {
|
|
695
|
+
const fromSlot = m.from_slot_id != null ? slotMap.get(m.from_slot_id) : null;
|
|
696
|
+
const fromName = (m as any).from_display_name ?? fromSlot?.display_name ?? m.from_id ?? "system";
|
|
697
|
+
const toName = (m as any).to_display_name ?? (m.to_slot_id != null ? slotMap.get(m.to_slot_id)?.display_name : null) ?? null;
|
|
698
|
+
|
|
699
|
+
const time = formatTime(m.sent_at);
|
|
700
|
+
const msgType = colorMsgType(m.msg_type);
|
|
701
|
+
|
|
702
|
+
// Color sender name
|
|
703
|
+
const senderColor = fromName === "orchestrator"
|
|
704
|
+
? `${MAGENTA}${fromName}${RESET}`
|
|
705
|
+
: `${BOLD}${fromName}${RESET}`;
|
|
706
|
+
|
|
707
|
+
// Build sender → recipient label
|
|
708
|
+
const arrow = toName ? ` ${DIM}→${RESET} ${WHITE}${toName}${RESET}` : "";
|
|
709
|
+
|
|
710
|
+
const headerLen = 8 + visibleLength(fromName) + (toName ? 3 + visibleLength(toName) : 0) + visibleLength(m.msg_type) + 4;
|
|
711
|
+
const maxText = Math.max(20, cols - headerLen - 2);
|
|
712
|
+
const text = truncate(String(m.text).replace(/\n/g, " ↵ "), maxText);
|
|
713
|
+
|
|
714
|
+
lines.push(` ${DIM}${time}${RESET} ${senderColor}${arrow} ${msgType} ${text}`);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Scroll indicator
|
|
718
|
+
const scrollPct = state.messages.length <= visibleCount ? 100 : Math.round((state.scrollOffset / maxScroll) * 100);
|
|
719
|
+
const autoIndicator = state.autoScroll ? `${GREEN}auto-scroll${RESET}` : `${DIM}manual${RESET}`;
|
|
720
|
+
lines.push(`${DIM} ${state.messages.length} messages ${scrollPct}% ${autoIndicator}${RESET}`);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// === TAB: STATS ===
|
|
724
|
+
function renderStatsTab(state: DashboardState, cols: number, maxRows: number, lines: string[]): void {
|
|
725
|
+
if (state.guardrails.length === 0) {
|
|
726
|
+
lines.push("");
|
|
727
|
+
lines.push(`${DIM} No stats available${RESET}`);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Separate monitoring stats from enforced guardrails
|
|
732
|
+
const monitorStats = state.guardrails.filter((g) => g.action === "monitor");
|
|
733
|
+
const enforced = state.guardrails.filter((g) => g.action !== "monitor");
|
|
734
|
+
|
|
735
|
+
// --- Monitoring stats section ---
|
|
736
|
+
if (monitorStats.length > 0) {
|
|
737
|
+
lines.push("");
|
|
738
|
+
lines.push(`${BOLD} Session Metrics${RESET}`);
|
|
739
|
+
lines.push("");
|
|
740
|
+
|
|
741
|
+
// Render stats in a compact grid (2 columns if terminal is wide enough)
|
|
742
|
+
const wide = cols >= 80;
|
|
743
|
+
const items: string[] = [];
|
|
744
|
+
|
|
745
|
+
for (const g of monitorStats) {
|
|
746
|
+
const current = g.usage ? formatStatValue(g.usage.current, g.unit) : "—";
|
|
747
|
+
const label = g.label;
|
|
748
|
+
items.push(` ${CYAN}${current.padStart(8)}${RESET} ${label}`);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (wide && items.length > 1) {
|
|
752
|
+
// 2-column layout
|
|
753
|
+
const mid = Math.ceil(items.length / 2);
|
|
754
|
+
for (let i = 0; i < mid; i++) {
|
|
755
|
+
const left = items[i] ?? "";
|
|
756
|
+
const right = items[i + mid] ?? "";
|
|
757
|
+
lines.push(`${left.padEnd(40)}${right}`);
|
|
758
|
+
}
|
|
759
|
+
} else {
|
|
760
|
+
for (const item of items) {
|
|
761
|
+
lines.push(item);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Token usage section
|
|
766
|
+
const slotsWithTokens = state.slots.filter((s) => (s.input_tokens ?? 0) + (s.output_tokens ?? 0) > 0);
|
|
767
|
+
if (slotsWithTokens.length > 0) {
|
|
768
|
+
lines.push("");
|
|
769
|
+
lines.push(`${BOLD} Token Usage${RESET}`);
|
|
770
|
+
lines.push("");
|
|
771
|
+
|
|
772
|
+
let totalInput = 0;
|
|
773
|
+
let totalOutput = 0;
|
|
774
|
+
let totalCache = 0;
|
|
775
|
+
|
|
776
|
+
for (const slot of slotsWithTokens) {
|
|
777
|
+
const inp = slot.input_tokens ?? 0;
|
|
778
|
+
const out = slot.output_tokens ?? 0;
|
|
779
|
+
const cache = slot.cache_read_tokens ?? 0;
|
|
780
|
+
totalInput += inp;
|
|
781
|
+
totalOutput += out;
|
|
782
|
+
totalCache += cache;
|
|
783
|
+
|
|
784
|
+
const name = truncate(slot.display_name ?? `Slot ${slot.id}`, 16);
|
|
785
|
+
const inStr = formatTokenCount(inp);
|
|
786
|
+
const outStr = formatTokenCount(out);
|
|
787
|
+
const cacheStr = cache > 0 ? ` ${DIM}cache:${RESET} ${formatTokenCount(cache)}` : "";
|
|
788
|
+
lines.push(` ${name.padEnd(18)} ${DIM}in:${RESET} ${CYAN}${inStr.padStart(7)}${RESET} ${DIM}out:${RESET} ${GREEN}${outStr.padStart(7)}${RESET}${cacheStr}`);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Totals row
|
|
792
|
+
lines.push(`${DIM} ${"─".repeat(50)}${RESET}`);
|
|
793
|
+
const totalStr = formatTokenCount(totalInput + totalOutput);
|
|
794
|
+
const cacheStr = totalCache > 0 ? ` ${DIM}cache:${RESET} ${formatTokenCount(totalCache)}` : "";
|
|
795
|
+
lines.push(` ${"Total".padEnd(18)} ${DIM}in:${RESET} ${CYAN}${formatTokenCount(totalInput).padStart(7)}${RESET} ${DIM}out:${RESET} ${GREEN}${formatTokenCount(totalOutput).padStart(7)}${RESET}${cacheStr} ${BOLD}= ${totalStr}${RESET}`);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Interaction summary
|
|
799
|
+
if (state.messages.length > 0) {
|
|
800
|
+
lines.push("");
|
|
801
|
+
lines.push(`${BOLD} Interaction Summary${RESET}`);
|
|
802
|
+
lines.push("");
|
|
803
|
+
|
|
804
|
+
const slotMap = new Map(state.slots.map((s) => [s.id, s]));
|
|
805
|
+
// Count messages per sender
|
|
806
|
+
const senderCounts = new Map<string, number>();
|
|
807
|
+
// Count unique interactions (sender→recipient pairs)
|
|
808
|
+
const interactions = new Map<string, number>();
|
|
809
|
+
|
|
810
|
+
for (const m of state.messages) {
|
|
811
|
+
const fromName = (m as any).from_display_name ?? slotMap.get(m.from_slot_id ?? -1)?.display_name ?? m.from_id ?? "system";
|
|
812
|
+
const toName = (m as any).to_display_name ?? (m.to_slot_id != null ? slotMap.get(m.to_slot_id)?.display_name : null) ?? "broadcast";
|
|
813
|
+
|
|
814
|
+
senderCounts.set(fromName, (senderCounts.get(fromName) ?? 0) + 1);
|
|
815
|
+
|
|
816
|
+
if (fromName !== "orchestrator") {
|
|
817
|
+
const key = `${fromName} → ${toName}`;
|
|
818
|
+
interactions.set(key, (interactions.get(key) ?? 0) + 1);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Top senders
|
|
823
|
+
const topSenders = [...senderCounts.entries()]
|
|
824
|
+
.sort((a, b) => b[1] - a[1])
|
|
825
|
+
.slice(0, 6);
|
|
826
|
+
|
|
827
|
+
for (const [name, count] of topSenders) {
|
|
828
|
+
const bar = "█".repeat(Math.min(20, Math.round(count / Math.max(1, topSenders[0][1]) * 20)));
|
|
829
|
+
lines.push(` ${name.padEnd(18)} ${CYAN}${bar}${RESET} ${DIM}${count}${RESET}`);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// Top interactions
|
|
833
|
+
if (interactions.size > 0) {
|
|
834
|
+
lines.push("");
|
|
835
|
+
lines.push(`${BOLD} Top Interactions${RESET}`);
|
|
836
|
+
lines.push("");
|
|
837
|
+
const topInteractions = [...interactions.entries()]
|
|
838
|
+
.sort((a, b) => b[1] - a[1])
|
|
839
|
+
.slice(0, 5);
|
|
840
|
+
|
|
841
|
+
for (const [pair, count] of topInteractions) {
|
|
842
|
+
lines.push(` ${DIM}${String(count).padStart(4)}×${RESET} ${pair}`);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// --- Enforced guardrails section ---
|
|
849
|
+
if (enforced.length > 0) {
|
|
850
|
+
lines.push("");
|
|
851
|
+
lines.push(`${BOLD} Guardrails${RESET}`);
|
|
852
|
+
lines.push("");
|
|
853
|
+
|
|
854
|
+
for (let i = 0; i < enforced.length; i++) {
|
|
855
|
+
const g = enforced[i];
|
|
856
|
+
// Adjust selectedRow to only count enforced items
|
|
857
|
+
const enforcedIdx = state.guardrails.indexOf(g);
|
|
858
|
+
const isSelected = enforcedIdx === state.selectedRow;
|
|
859
|
+
const prefix = isSelected ? `${CYAN}▸${RESET}` : " ";
|
|
860
|
+
|
|
861
|
+
if (!g.usage) {
|
|
862
|
+
lines.push(`${prefix} ${g.label.padEnd(22)} ${DIM}no data${RESET}`);
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const bar = progressBar(g.usage.percent, 15);
|
|
867
|
+
const label = truncate(g.label, 20).padEnd(22);
|
|
868
|
+
const current = formatStatValue(g.usage.current, g.unit);
|
|
869
|
+
const usage = `${current}/${g.usage.limit} ${g.unit}`;
|
|
870
|
+
const status = colorStatus(g.usage.status);
|
|
871
|
+
|
|
872
|
+
lines.push(`${prefix}${label}${bar} ${usage.padEnd(20)} ${status}`);
|
|
873
|
+
|
|
874
|
+
if (isSelected && g.adjustable && g.suggested_increases?.length > 0) {
|
|
875
|
+
const opts = g.suggested_increases
|
|
876
|
+
.map((v) => v === g.current_value ? `${BOLD}[${v}]${RESET}` : `${DIM}${v}${RESET}`)
|
|
877
|
+
.join(" ");
|
|
878
|
+
lines.push(` ${DIM}└─ Press ${RESET}${BOLD}+/-${RESET}${DIM} to adjust:${RESET} ${opts} ${g.unit}`);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function formatStatValue(value: number, unit: string): string {
|
|
885
|
+
if (unit === "minutes") {
|
|
886
|
+
if (value < 1) return `${Math.round(value * 60)}s`;
|
|
887
|
+
if (value < 60) return `${value.toFixed(1)}m`;
|
|
888
|
+
const h = Math.floor(value / 60);
|
|
889
|
+
const m = Math.round(value % 60);
|
|
890
|
+
return `${h}h${m}m`;
|
|
891
|
+
}
|
|
892
|
+
return Number.isInteger(value) ? String(value) : value.toFixed(1);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function formatTokenCount(count: number): string {
|
|
896
|
+
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
897
|
+
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
|
|
898
|
+
return String(count);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// === TAB: PLAN ===
|
|
902
|
+
function renderPlanTab(state: DashboardState, cols: number, maxRows: number, lines: string[]): void {
|
|
903
|
+
const ps = state.planState;
|
|
904
|
+
|
|
905
|
+
if (!ps?.plan || ps.items.length === 0) {
|
|
906
|
+
lines.push("");
|
|
907
|
+
lines.push(`${DIM} No plan defined for this session${RESET}`);
|
|
908
|
+
lines.push(`${DIM} Pass a plan array to create_team to track progress${RESET}`);
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Header with progress bar
|
|
913
|
+
const pct = ps.completion / 100;
|
|
914
|
+
const bar = progressBar(pct, 30);
|
|
915
|
+
const done = ps.items.filter((i) => i.status === "done").length;
|
|
916
|
+
lines.push("");
|
|
917
|
+
lines.push(` ${BOLD}${ps.plan.title}${RESET} ${bar} ${BOLD}${ps.completion}%${RESET} ${DIM}(${done}/${ps.items.length})${RESET}`);
|
|
918
|
+
lines.push("");
|
|
919
|
+
|
|
920
|
+
// Plan items
|
|
921
|
+
for (const item of ps.items) {
|
|
922
|
+
const indent = item.parent_id ? " " : " ";
|
|
923
|
+
let marker: string;
|
|
924
|
+
let labelColor: string;
|
|
925
|
+
|
|
926
|
+
switch (item.status) {
|
|
927
|
+
case "done":
|
|
928
|
+
marker = `${GREEN}[x]${RESET}`;
|
|
929
|
+
labelColor = DIM;
|
|
930
|
+
break;
|
|
931
|
+
case "in_progress":
|
|
932
|
+
marker = `${CYAN}[~]${RESET}`;
|
|
933
|
+
labelColor = BOLD;
|
|
934
|
+
break;
|
|
935
|
+
case "blocked":
|
|
936
|
+
marker = `${RED}[!]${RESET}`;
|
|
937
|
+
labelColor = RED;
|
|
938
|
+
break;
|
|
939
|
+
default: // pending
|
|
940
|
+
marker = `${DIM}[ ]${RESET}`;
|
|
941
|
+
labelColor = "";
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const assignee = item.assigned_name ? `${DIM}${item.assigned_name}${RESET}` : "";
|
|
945
|
+
const statusTag = item.status === "in_progress" ? ` ${CYAN}(in progress)${RESET}` : "";
|
|
946
|
+
const label = `${labelColor}${item.label}${labelColor ? RESET : ""}`;
|
|
947
|
+
|
|
948
|
+
const itemLine = `${indent}${marker} ${label}${statusTag}`;
|
|
949
|
+
const assigneePad = assignee ? " " + assignee : "";
|
|
950
|
+
|
|
951
|
+
// Right-align assignee
|
|
952
|
+
const visLen = visibleLength(itemLine);
|
|
953
|
+
const assigneeVisLen = visibleLength(assigneePad);
|
|
954
|
+
const gap = Math.max(1, cols - visLen - assigneeVisLen - 2);
|
|
955
|
+
|
|
956
|
+
lines.push(`${itemLine}${" ".repeat(gap)}${assigneePad}`);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// Summary at bottom
|
|
960
|
+
const inProgress = ps.items.filter((i) => i.status === "in_progress").length;
|
|
961
|
+
const blocked = ps.items.filter((i) => i.status === "blocked").length;
|
|
962
|
+
const pending = ps.items.filter((i) => i.status === "pending").length;
|
|
963
|
+
lines.push("");
|
|
964
|
+
lines.push(` ${GREEN}${done} done${RESET} ${CYAN}${inProgress} in progress${RESET} ${pending > 0 ? `${DIM}${pending} pending${RESET} ` : ""}${blocked > 0 ? `${RED}${blocked} blocked${RESET}` : ""}`);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// === TAB: FILES ===
|
|
968
|
+
function renderFilesTab(state: DashboardState, cols: number, maxRows: number, lines: string[]): void {
|
|
969
|
+
if (state.fileLocks.length === 0) {
|
|
970
|
+
lines.push("");
|
|
971
|
+
lines.push(`${DIM} No active file locks${RESET}`);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
lines.push("");
|
|
976
|
+
lines.push(`${DIM} ${"File".padEnd(45)}${"Held By".padEnd(20)}${"Type".padEnd(12)}${RESET}`);
|
|
977
|
+
lines.push(`${DIM} ${"─".repeat(Math.min(cols - 2, 77))}${RESET}`);
|
|
978
|
+
|
|
979
|
+
const slotMap = new Map(state.slots.map((s) => [s.id, s]));
|
|
980
|
+
|
|
981
|
+
for (const lock of state.fileLocks) {
|
|
982
|
+
const file = truncate(lock.file_path, 43);
|
|
983
|
+
const slot = slotMap.get(lock.held_by_slot);
|
|
984
|
+
const holder = truncate(slot?.display_name ?? `Slot ${lock.held_by_slot}`, 18);
|
|
985
|
+
const type = lock.lock_type;
|
|
986
|
+
lines.push(` ${file.padEnd(45)}${holder.padEnd(20)}${DIM}${type}${RESET}`);
|
|
987
|
+
}
|
|
988
|
+
}
|