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.
@@ -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
+ }