@os-eco/overstory-cli 0.6.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/LICENSE +21 -0
- package/README.md +381 -0
- package/agents/builder.md +137 -0
- package/agents/coordinator.md +263 -0
- package/agents/lead.md +301 -0
- package/agents/merger.md +160 -0
- package/agents/monitor.md +214 -0
- package/agents/reviewer.md +140 -0
- package/agents/scout.md +119 -0
- package/agents/supervisor.md +423 -0
- package/package.json +47 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +101 -0
- package/src/agents/hooks-deployer.test.ts +2040 -0
- package/src/agents/hooks-deployer.ts +607 -0
- package/src/agents/identity.test.ts +603 -0
- package/src/agents/identity.ts +384 -0
- package/src/agents/lifecycle.test.ts +196 -0
- package/src/agents/lifecycle.ts +183 -0
- package/src/agents/manifest.test.ts +746 -0
- package/src/agents/manifest.ts +354 -0
- package/src/agents/overlay.test.ts +676 -0
- package/src/agents/overlay.ts +308 -0
- package/src/beads/client.test.ts +217 -0
- package/src/beads/client.ts +202 -0
- package/src/beads/molecules.test.ts +338 -0
- package/src/beads/molecules.ts +198 -0
- package/src/commands/agents.test.ts +322 -0
- package/src/commands/agents.ts +287 -0
- package/src/commands/clean.test.ts +670 -0
- package/src/commands/clean.ts +618 -0
- package/src/commands/completions.test.ts +342 -0
- package/src/commands/completions.ts +887 -0
- package/src/commands/coordinator.test.ts +1530 -0
- package/src/commands/coordinator.ts +733 -0
- package/src/commands/costs.test.ts +1119 -0
- package/src/commands/costs.ts +564 -0
- package/src/commands/dashboard.test.ts +308 -0
- package/src/commands/dashboard.ts +838 -0
- package/src/commands/doctor.test.ts +294 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/errors.test.ts +647 -0
- package/src/commands/errors.ts +248 -0
- package/src/commands/feed.test.ts +578 -0
- package/src/commands/feed.ts +361 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +511 -0
- package/src/commands/hooks.test.ts +458 -0
- package/src/commands/hooks.ts +253 -0
- package/src/commands/init.test.ts +347 -0
- package/src/commands/init.ts +650 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +431 -0
- package/src/commands/log.test.ts +1454 -0
- package/src/commands/log.ts +724 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +546 -0
- package/src/commands/mail.test.ts +1270 -0
- package/src/commands/mail.ts +771 -0
- package/src/commands/merge.test.ts +670 -0
- package/src/commands/merge.ts +355 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +143 -0
- package/src/commands/monitor.test.ts +191 -0
- package/src/commands/monitor.ts +390 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +372 -0
- package/src/commands/prime.test.ts +470 -0
- package/src/commands/prime.ts +381 -0
- package/src/commands/replay.test.ts +741 -0
- package/src/commands/replay.ts +360 -0
- package/src/commands/run.test.ts +431 -0
- package/src/commands/run.ts +351 -0
- package/src/commands/sling.test.ts +657 -0
- package/src/commands/sling.ts +661 -0
- package/src/commands/spec.test.ts +203 -0
- package/src/commands/spec.ts +168 -0
- package/src/commands/status.test.ts +430 -0
- package/src/commands/status.ts +398 -0
- package/src/commands/stop.test.ts +420 -0
- package/src/commands/stop.ts +151 -0
- package/src/commands/supervisor.test.ts +187 -0
- package/src/commands/supervisor.ts +535 -0
- package/src/commands/trace.test.ts +745 -0
- package/src/commands/trace.ts +325 -0
- package/src/commands/watch.test.ts +145 -0
- package/src/commands/watch.ts +247 -0
- package/src/commands/worktree.test.ts +786 -0
- package/src/commands/worktree.ts +311 -0
- package/src/config.test.ts +822 -0
- package/src/config.ts +829 -0
- package/src/doctor/agents.test.ts +454 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +190 -0
- package/src/doctor/config-check.ts +183 -0
- package/src/doctor/consistency.test.ts +651 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +290 -0
- package/src/doctor/databases.ts +218 -0
- package/src/doctor/dependencies.test.ts +184 -0
- package/src/doctor/dependencies.ts +175 -0
- package/src/doctor/logs.test.ts +251 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +216 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +291 -0
- package/src/doctor/structure.ts +198 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +136 -0
- package/src/doctor/version.ts +129 -0
- package/src/e2e/init-sling-lifecycle.test.ts +277 -0
- package/src/errors.ts +217 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +369 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/index.ts +316 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +142 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +813 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +259 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +773 -0
- package/src/mail/client.ts +223 -0
- package/src/mail/store.test.ts +705 -0
- package/src/mail/store.ts +387 -0
- package/src/merge/queue.test.ts +359 -0
- package/src/merge/queue.ts +231 -0
- package/src/merge/resolver.test.ts +1345 -0
- package/src/merge/resolver.ts +645 -0
- package/src/metrics/store.test.ts +667 -0
- package/src/metrics/store.ts +445 -0
- package/src/metrics/summary.test.ts +398 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +356 -0
- package/src/metrics/transcript.ts +175 -0
- package/src/mulch/client.test.ts +671 -0
- package/src/mulch/client.ts +332 -0
- package/src/sessions/compat.test.ts +280 -0
- package/src/sessions/compat.ts +104 -0
- package/src/sessions/store.test.ts +873 -0
- package/src/sessions/store.ts +494 -0
- package/src/test-helpers.test.ts +124 -0
- package/src/test-helpers.ts +126 -0
- package/src/tracker/beads.ts +56 -0
- package/src/tracker/factory.test.ts +80 -0
- package/src/tracker/factory.ts +64 -0
- package/src/tracker/seeds.ts +182 -0
- package/src/tracker/types.ts +52 -0
- package/src/types.ts +724 -0
- package/src/watchdog/daemon.test.ts +1975 -0
- package/src/watchdog/daemon.ts +671 -0
- package/src/watchdog/health.test.ts +431 -0
- package/src/watchdog/health.ts +264 -0
- package/src/watchdog/triage.test.ts +164 -0
- package/src/watchdog/triage.ts +179 -0
- package/src/worktree/manager.test.ts +439 -0
- package/src/worktree/manager.ts +198 -0
- package/src/worktree/tmux.test.ts +1009 -0
- package/src/worktree/tmux.ts +509 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +105 -0
- package/templates/overlay.md.tmpl +81 -0
|
@@ -0,0 +1,838 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: overstory dashboard [--interval <ms>] [--all]
|
|
3
|
+
*
|
|
4
|
+
* Rich terminal dashboard using raw ANSI escape codes (zero runtime deps).
|
|
5
|
+
* Polls existing data sources and renders multi-panel layout with agent status,
|
|
6
|
+
* mail activity, merge queue, and metrics.
|
|
7
|
+
*
|
|
8
|
+
* By default, all panels are scoped to the current run (current-run.txt).
|
|
9
|
+
* Use --all to show data across all runs.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { loadConfig } from "../config.ts";
|
|
15
|
+
import { ValidationError } from "../errors.ts";
|
|
16
|
+
import { color } from "../logging/color.ts";
|
|
17
|
+
import { createMailStore, type MailStore } from "../mail/store.ts";
|
|
18
|
+
import { createMergeQueue, type MergeQueue } from "../merge/queue.ts";
|
|
19
|
+
import { createMetricsStore, type MetricsStore } from "../metrics/store.ts";
|
|
20
|
+
import { openSessionStore } from "../sessions/compat.ts";
|
|
21
|
+
import type { SessionStore } from "../sessions/store.ts";
|
|
22
|
+
import type { MailMessage } from "../types.ts";
|
|
23
|
+
import { evaluateHealth } from "../watchdog/health.ts";
|
|
24
|
+
import { getCachedTmuxSessions, getCachedWorktrees, type StatusData } from "./status.ts";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Terminal control codes (cursor movement, screen clearing).
|
|
28
|
+
* These are not colors, so they stay separate from the color module.
|
|
29
|
+
*/
|
|
30
|
+
const CURSOR = {
|
|
31
|
+
clear: "\x1b[2J\x1b[H", // Clear screen and home cursor
|
|
32
|
+
cursorTo: (row: number, col: number) => `\x1b[${row};${col}H`,
|
|
33
|
+
hideCursor: "\x1b[?25l",
|
|
34
|
+
showCursor: "\x1b[?25h",
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Box drawing characters for panel borders.
|
|
39
|
+
*/
|
|
40
|
+
const BOX = {
|
|
41
|
+
topLeft: "┌",
|
|
42
|
+
topRight: "┐",
|
|
43
|
+
bottomLeft: "└",
|
|
44
|
+
bottomRight: "┘",
|
|
45
|
+
horizontal: "─",
|
|
46
|
+
vertical: "│",
|
|
47
|
+
tee: "├",
|
|
48
|
+
teeRight: "┤",
|
|
49
|
+
cross: "┼",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Parse a named flag value from args.
|
|
54
|
+
*/
|
|
55
|
+
function getFlag(args: string[], flag: string): string | undefined {
|
|
56
|
+
const idx = args.indexOf(flag);
|
|
57
|
+
if (idx === -1 || idx + 1 >= args.length) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
return args[idx + 1];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Format a duration in ms to a human-readable string.
|
|
65
|
+
*/
|
|
66
|
+
function formatDuration(ms: number): string {
|
|
67
|
+
const seconds = Math.floor(ms / 1000);
|
|
68
|
+
if (seconds < 60) return `${seconds}s`;
|
|
69
|
+
const minutes = Math.floor(seconds / 60);
|
|
70
|
+
const remainSec = seconds % 60;
|
|
71
|
+
if (minutes < 60) return `${minutes}m ${remainSec}s`;
|
|
72
|
+
const hours = Math.floor(minutes / 60);
|
|
73
|
+
const remainMin = minutes % 60;
|
|
74
|
+
return `${hours}h ${remainMin}m`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Format a timestamp to "time ago" format.
|
|
79
|
+
*/
|
|
80
|
+
function timeAgo(timestamp: string): string {
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
const then = new Date(timestamp).getTime();
|
|
83
|
+
const diffMs = now - then;
|
|
84
|
+
const diffSec = Math.floor(diffMs / 1000);
|
|
85
|
+
|
|
86
|
+
if (diffSec < 60) return `${diffSec}s ago`;
|
|
87
|
+
const diffMin = Math.floor(diffSec / 60);
|
|
88
|
+
if (diffMin < 60) return `${diffMin}m ago`;
|
|
89
|
+
const diffHr = Math.floor(diffMin / 60);
|
|
90
|
+
if (diffHr < 24) return `${diffHr}h ago`;
|
|
91
|
+
const diffDay = Math.floor(diffHr / 24);
|
|
92
|
+
return `${diffDay}d ago`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Truncate a string to fit within maxLen characters, adding ellipsis if needed.
|
|
97
|
+
*/
|
|
98
|
+
function truncate(str: string, maxLen: number): string {
|
|
99
|
+
if (maxLen <= 0) return "";
|
|
100
|
+
if (str.length <= maxLen) return str;
|
|
101
|
+
return `${str.slice(0, maxLen - 1)}…`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Pad or truncate a string to exactly the given width.
|
|
106
|
+
*/
|
|
107
|
+
function pad(str: string, width: number): string {
|
|
108
|
+
if (width <= 0) return "";
|
|
109
|
+
if (str.length >= width) return str.slice(0, width);
|
|
110
|
+
return str + " ".repeat(width - str.length);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Draw a horizontal line with left/right/middle connectors.
|
|
115
|
+
*/
|
|
116
|
+
function horizontalLine(width: number, left: string, _middle: string, right: string): string {
|
|
117
|
+
return left + BOX.horizontal.repeat(Math.max(0, width - 2)) + right;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export { pad, truncate, horizontalLine };
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Filter agents by run ID. When run-scoped, also includes sessions with null
|
|
124
|
+
* runId (e.g. coordinator) because SQL WHERE run_id = ? never matches NULL.
|
|
125
|
+
*/
|
|
126
|
+
export function filterAgentsByRun<T extends { runId: string | null }>(
|
|
127
|
+
agents: T[],
|
|
128
|
+
runId: string | null | undefined,
|
|
129
|
+
): T[] {
|
|
130
|
+
if (!runId) return agents;
|
|
131
|
+
return agents.filter((a) => a.runId === runId || a.runId === null);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Pre-opened database handles for the dashboard poll loop.
|
|
136
|
+
* Stores are opened once and reused across ticks to avoid
|
|
137
|
+
* repeated open/close/PRAGMA/WAL checkpoint overhead.
|
|
138
|
+
*/
|
|
139
|
+
export interface DashboardStores {
|
|
140
|
+
sessionStore: SessionStore;
|
|
141
|
+
mailStore: MailStore | null;
|
|
142
|
+
mergeQueue: MergeQueue | null;
|
|
143
|
+
metricsStore: MetricsStore | null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Open all database connections needed by the dashboard.
|
|
148
|
+
* Returns null handles for databases that do not exist on disk.
|
|
149
|
+
*/
|
|
150
|
+
export function openDashboardStores(root: string): DashboardStores {
|
|
151
|
+
const overstoryDir = join(root, ".overstory");
|
|
152
|
+
const { store: sessionStore } = openSessionStore(overstoryDir);
|
|
153
|
+
|
|
154
|
+
let mailStore: MailStore | null = null;
|
|
155
|
+
try {
|
|
156
|
+
const mailDbPath = join(overstoryDir, "mail.db");
|
|
157
|
+
if (existsSync(mailDbPath)) {
|
|
158
|
+
mailStore = createMailStore(mailDbPath);
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
// mail db might not be openable
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let mergeQueue: MergeQueue | null = null;
|
|
165
|
+
try {
|
|
166
|
+
const queuePath = join(overstoryDir, "merge-queue.db");
|
|
167
|
+
if (existsSync(queuePath)) {
|
|
168
|
+
mergeQueue = createMergeQueue(queuePath);
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
// queue db might not be openable
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let metricsStore: MetricsStore | null = null;
|
|
175
|
+
try {
|
|
176
|
+
const metricsDbPath = join(overstoryDir, "metrics.db");
|
|
177
|
+
if (existsSync(metricsDbPath)) {
|
|
178
|
+
metricsStore = createMetricsStore(metricsDbPath);
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
// metrics db might not be openable
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return { sessionStore, mailStore, mergeQueue, metricsStore };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Close all dashboard database connections.
|
|
189
|
+
*/
|
|
190
|
+
export function closeDashboardStores(stores: DashboardStores): void {
|
|
191
|
+
try {
|
|
192
|
+
stores.sessionStore.close();
|
|
193
|
+
} catch {
|
|
194
|
+
/* best effort */
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
stores.mailStore?.close();
|
|
198
|
+
} catch {
|
|
199
|
+
/* best effort */
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
stores.mergeQueue?.close();
|
|
203
|
+
} catch {
|
|
204
|
+
/* best effort */
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
stores.metricsStore?.close();
|
|
208
|
+
} catch {
|
|
209
|
+
/* best effort */
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
interface DashboardData {
|
|
214
|
+
currentRunId?: string | null;
|
|
215
|
+
status: StatusData;
|
|
216
|
+
recentMail: MailMessage[];
|
|
217
|
+
mergeQueue: Array<{ branchName: string; agentName: string; status: string }>;
|
|
218
|
+
metrics: {
|
|
219
|
+
totalSessions: number;
|
|
220
|
+
avgDuration: number;
|
|
221
|
+
byCapability: Record<string, number>;
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Read the current run ID from current-run.txt, or null if no active run.
|
|
227
|
+
*/
|
|
228
|
+
async function readCurrentRunId(overstoryDir: string): Promise<string | null> {
|
|
229
|
+
const path = join(overstoryDir, "current-run.txt");
|
|
230
|
+
const file = Bun.file(path);
|
|
231
|
+
if (!(await file.exists())) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
const text = await file.text();
|
|
235
|
+
const trimmed = text.trim();
|
|
236
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Load all data sources for the dashboard using pre-opened store handles.
|
|
241
|
+
* When runId is provided, all panels are scoped to agents in that run.
|
|
242
|
+
* No stores are opened or closed here — that is the caller's responsibility.
|
|
243
|
+
*/
|
|
244
|
+
async function loadDashboardData(
|
|
245
|
+
root: string,
|
|
246
|
+
stores: DashboardStores,
|
|
247
|
+
runId?: string | null,
|
|
248
|
+
thresholds?: { staleMs: number; zombieMs: number },
|
|
249
|
+
): Promise<DashboardData> {
|
|
250
|
+
// Get all sessions from the pre-opened session store
|
|
251
|
+
const allSessions = stores.sessionStore.getAll();
|
|
252
|
+
|
|
253
|
+
// Get worktrees and tmux sessions via cached subprocess helpers
|
|
254
|
+
const worktrees = await getCachedWorktrees(root);
|
|
255
|
+
const tmuxSessions = await getCachedTmuxSessions();
|
|
256
|
+
|
|
257
|
+
// Evaluate health for active agents using the same logic as the watchdog.
|
|
258
|
+
// This handles two key cases:
|
|
259
|
+
// 1. tmux dead -> zombie (previously the only reconciliation)
|
|
260
|
+
// 2. persistent capabilities (coordinator, monitor) booting -> working when tmux alive
|
|
261
|
+
const tmuxSessionNames = new Set(tmuxSessions.map((s) => s.name));
|
|
262
|
+
const healthThresholds = thresholds ?? { staleMs: 300_000, zombieMs: 600_000 };
|
|
263
|
+
for (const session of allSessions) {
|
|
264
|
+
if (session.state === "completed") continue;
|
|
265
|
+
const tmuxAlive = tmuxSessionNames.has(session.tmuxSession);
|
|
266
|
+
const check = evaluateHealth(session, tmuxAlive, healthThresholds);
|
|
267
|
+
if (check.state !== session.state) {
|
|
268
|
+
try {
|
|
269
|
+
stores.sessionStore.updateState(session.agentName, check.state);
|
|
270
|
+
session.state = check.state;
|
|
271
|
+
} catch {
|
|
272
|
+
// Best effort: don't fail dashboard if update fails
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// If run-scoped, filter agents to only those belonging to the current run.
|
|
278
|
+
// Also includes null-runId sessions (e.g. coordinator) per filterAgentsByRun logic.
|
|
279
|
+
const filteredAgents = filterAgentsByRun(allSessions, runId);
|
|
280
|
+
|
|
281
|
+
// Count unread mail
|
|
282
|
+
let unreadMailCount = 0;
|
|
283
|
+
if (stores.mailStore) {
|
|
284
|
+
try {
|
|
285
|
+
const unread = stores.mailStore.getAll({ to: "orchestrator", unread: true });
|
|
286
|
+
unreadMailCount = unread.length;
|
|
287
|
+
} catch {
|
|
288
|
+
// best effort
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Count merge queue pending entries
|
|
293
|
+
let mergeQueueCount = 0;
|
|
294
|
+
if (stores.mergeQueue) {
|
|
295
|
+
try {
|
|
296
|
+
mergeQueueCount = stores.mergeQueue.list("pending").length;
|
|
297
|
+
} catch {
|
|
298
|
+
// best effort
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Count recent metrics sessions
|
|
303
|
+
let recentMetricsCount = 0;
|
|
304
|
+
if (stores.metricsStore) {
|
|
305
|
+
try {
|
|
306
|
+
recentMetricsCount = stores.metricsStore.getRecentSessions(100).length;
|
|
307
|
+
} catch {
|
|
308
|
+
// best effort
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const status: StatusData = {
|
|
313
|
+
currentRunId: runId,
|
|
314
|
+
agents: filteredAgents,
|
|
315
|
+
worktrees,
|
|
316
|
+
tmuxSessions,
|
|
317
|
+
unreadMailCount,
|
|
318
|
+
mergeQueueCount,
|
|
319
|
+
recentMetricsCount,
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
// Load recent mail from pre-opened mail store
|
|
323
|
+
let recentMail: MailMessage[] = [];
|
|
324
|
+
if (stores.mailStore) {
|
|
325
|
+
try {
|
|
326
|
+
if (runId && filteredAgents.length > 0) {
|
|
327
|
+
const agentNames = new Set(filteredAgents.map((a) => a.agentName));
|
|
328
|
+
// Fetch a small batch to filter from; can't push agent-set filter into SQL
|
|
329
|
+
const allMail = stores.mailStore.getAll({ limit: 50 });
|
|
330
|
+
recentMail = allMail
|
|
331
|
+
.filter((m) => agentNames.has(m.from) || agentNames.has(m.to))
|
|
332
|
+
.slice(0, 5);
|
|
333
|
+
} else {
|
|
334
|
+
recentMail = stores.mailStore.getAll({ limit: 5 });
|
|
335
|
+
}
|
|
336
|
+
} catch {
|
|
337
|
+
// best effort
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Load merge queue entries from pre-opened merge queue
|
|
342
|
+
let mergeQueueEntries: Array<{ branchName: string; agentName: string; status: string }> = [];
|
|
343
|
+
if (stores.mergeQueue) {
|
|
344
|
+
try {
|
|
345
|
+
let entries = stores.mergeQueue.list();
|
|
346
|
+
if (runId && filteredAgents.length > 0) {
|
|
347
|
+
const agentNames = new Set(filteredAgents.map((a) => a.agentName));
|
|
348
|
+
entries = entries.filter((e) => agentNames.has(e.agentName));
|
|
349
|
+
}
|
|
350
|
+
mergeQueueEntries = entries.map((e) => ({
|
|
351
|
+
branchName: e.branchName,
|
|
352
|
+
agentName: e.agentName,
|
|
353
|
+
status: e.status,
|
|
354
|
+
}));
|
|
355
|
+
} catch {
|
|
356
|
+
// best effort
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Load metrics from pre-opened metrics store
|
|
361
|
+
let totalSessions = 0;
|
|
362
|
+
let avgDuration = 0;
|
|
363
|
+
const byCapability: Record<string, number> = {};
|
|
364
|
+
if (stores.metricsStore) {
|
|
365
|
+
try {
|
|
366
|
+
const sessions = stores.metricsStore.getRecentSessions(100);
|
|
367
|
+
|
|
368
|
+
const filtered =
|
|
369
|
+
runId && filteredAgents.length > 0
|
|
370
|
+
? (() => {
|
|
371
|
+
const agentNames = new Set(filteredAgents.map((a) => a.agentName));
|
|
372
|
+
return sessions.filter((s) => agentNames.has(s.agentName));
|
|
373
|
+
})()
|
|
374
|
+
: sessions;
|
|
375
|
+
|
|
376
|
+
totalSessions = filtered.length;
|
|
377
|
+
|
|
378
|
+
// When run-scoped, compute avg duration from filtered sessions manually
|
|
379
|
+
if (runId && filteredAgents.length > 0) {
|
|
380
|
+
const completedSessions = filtered.filter((s) => s.completedAt !== null);
|
|
381
|
+
if (completedSessions.length > 0) {
|
|
382
|
+
avgDuration =
|
|
383
|
+
completedSessions.reduce((sum, s) => sum + s.durationMs, 0) / completedSessions.length;
|
|
384
|
+
}
|
|
385
|
+
} else {
|
|
386
|
+
avgDuration = stores.metricsStore.getAverageDuration();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
for (const session of filtered) {
|
|
390
|
+
const cap = session.capability;
|
|
391
|
+
byCapability[cap] = (byCapability[cap] ?? 0) + 1;
|
|
392
|
+
}
|
|
393
|
+
} catch {
|
|
394
|
+
// best effort
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
currentRunId: runId,
|
|
400
|
+
status,
|
|
401
|
+
recentMail,
|
|
402
|
+
mergeQueue: mergeQueueEntries,
|
|
403
|
+
metrics: { totalSessions, avgDuration, byCapability },
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Render the header bar (line 1).
|
|
409
|
+
*/
|
|
410
|
+
function renderHeader(width: number, interval: number, currentRunId?: string | null): string {
|
|
411
|
+
const left = `${color.bold}overstory dashboard v0.2.0${color.reset}`;
|
|
412
|
+
const now = new Date().toLocaleTimeString();
|
|
413
|
+
const scope = currentRunId ? ` [run: ${currentRunId.slice(0, 8)}]` : " [all runs]";
|
|
414
|
+
const right = `${now}${scope} | refresh: ${interval}ms`;
|
|
415
|
+
const leftStripped = "overstory dashboard v0.2.0"; // for length calculation
|
|
416
|
+
const padding = width - leftStripped.length - right.length;
|
|
417
|
+
const line = left + " ".repeat(Math.max(0, padding)) + right;
|
|
418
|
+
const separator = horizontalLine(width, BOX.topLeft, BOX.horizontal, BOX.topRight);
|
|
419
|
+
return `${line}\n${separator}`;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Get color for agent state.
|
|
424
|
+
*/
|
|
425
|
+
function getStateColor(state: string): string {
|
|
426
|
+
switch (state) {
|
|
427
|
+
case "working":
|
|
428
|
+
return color.green;
|
|
429
|
+
case "booting":
|
|
430
|
+
return color.yellow;
|
|
431
|
+
case "stalled":
|
|
432
|
+
return color.red;
|
|
433
|
+
case "zombie":
|
|
434
|
+
return color.dim;
|
|
435
|
+
case "completed":
|
|
436
|
+
return color.cyan;
|
|
437
|
+
default:
|
|
438
|
+
return color.white;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Get status icon for agent state.
|
|
444
|
+
*/
|
|
445
|
+
function getStateIcon(state: string): string {
|
|
446
|
+
switch (state) {
|
|
447
|
+
case "working":
|
|
448
|
+
return "●";
|
|
449
|
+
case "booting":
|
|
450
|
+
return "◐";
|
|
451
|
+
case "stalled":
|
|
452
|
+
return "⚠";
|
|
453
|
+
case "zombie":
|
|
454
|
+
return "○";
|
|
455
|
+
case "completed":
|
|
456
|
+
return "✓";
|
|
457
|
+
default:
|
|
458
|
+
return "?";
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Render the agent panel (top ~40% of screen).
|
|
464
|
+
*/
|
|
465
|
+
function renderAgentPanel(
|
|
466
|
+
data: DashboardData,
|
|
467
|
+
width: number,
|
|
468
|
+
height: number,
|
|
469
|
+
startRow: number,
|
|
470
|
+
): string {
|
|
471
|
+
const panelHeight = Math.floor(height * 0.4);
|
|
472
|
+
let output = "";
|
|
473
|
+
|
|
474
|
+
// Panel header
|
|
475
|
+
const headerLine = `${BOX.vertical} ${color.bold}Agents${color.reset} (${data.status.agents.length})`;
|
|
476
|
+
const headerPadding = " ".repeat(
|
|
477
|
+
Math.max(0, width - headerLine.length - 1 + color.bold.length + color.reset.length),
|
|
478
|
+
);
|
|
479
|
+
output += `${CURSOR.cursorTo(startRow, 1)}${headerLine}${headerPadding}${BOX.vertical}\n`;
|
|
480
|
+
|
|
481
|
+
// Column headers
|
|
482
|
+
const colHeaders = `${BOX.vertical} St Name Capability State Bead ID Duration Tmux ${BOX.vertical}`;
|
|
483
|
+
output += `${CURSOR.cursorTo(startRow + 1, 1)}${colHeaders}\n`;
|
|
484
|
+
|
|
485
|
+
// Separator
|
|
486
|
+
const separator = horizontalLine(width, BOX.tee, BOX.horizontal, BOX.teeRight);
|
|
487
|
+
output += `${CURSOR.cursorTo(startRow + 2, 1)}${separator}\n`;
|
|
488
|
+
|
|
489
|
+
// Sort agents: active first (working, booting, stalled), then completed, then zombie
|
|
490
|
+
const agents = [...data.status.agents].sort((a, b) => {
|
|
491
|
+
const activeStates = ["working", "booting", "stalled"];
|
|
492
|
+
const aActive = activeStates.includes(a.state);
|
|
493
|
+
const bActive = activeStates.includes(b.state);
|
|
494
|
+
if (aActive && !bActive) return -1;
|
|
495
|
+
if (!aActive && bActive) return 1;
|
|
496
|
+
return 0;
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
const now = Date.now();
|
|
500
|
+
const maxRows = panelHeight - 4; // header + col headers + separator + border
|
|
501
|
+
const visibleAgents = agents.slice(0, maxRows);
|
|
502
|
+
|
|
503
|
+
for (let i = 0; i < visibleAgents.length; i++) {
|
|
504
|
+
const agent = visibleAgents[i];
|
|
505
|
+
if (!agent) continue;
|
|
506
|
+
|
|
507
|
+
const icon = getStateIcon(agent.state);
|
|
508
|
+
const stateColor = getStateColor(agent.state);
|
|
509
|
+
const name = pad(truncate(agent.agentName, 15), 15);
|
|
510
|
+
const capability = pad(truncate(agent.capability, 12), 12);
|
|
511
|
+
const state = pad(agent.state, 10);
|
|
512
|
+
const beadId = pad(truncate(agent.beadId, 16), 16);
|
|
513
|
+
const endTime =
|
|
514
|
+
agent.state === "completed" || agent.state === "zombie"
|
|
515
|
+
? new Date(agent.lastActivity).getTime()
|
|
516
|
+
: now;
|
|
517
|
+
const duration = formatDuration(endTime - new Date(agent.startedAt).getTime());
|
|
518
|
+
const durationPadded = pad(duration, 9);
|
|
519
|
+
const tmuxAlive = data.status.tmuxSessions.some((s) => s.name === agent.tmuxSession);
|
|
520
|
+
const tmuxDot = tmuxAlive ? `${color.green}●${color.reset}` : `${color.red}○${color.reset}`;
|
|
521
|
+
|
|
522
|
+
const line = `${BOX.vertical} ${stateColor}${icon}${color.reset} ${name} ${capability} ${stateColor}${state}${color.reset} ${beadId} ${durationPadded} ${tmuxDot} ${BOX.vertical}`;
|
|
523
|
+
output += `${CURSOR.cursorTo(startRow + 3 + i, 1)}${line}\n`;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Fill remaining rows with empty lines
|
|
527
|
+
for (let i = visibleAgents.length; i < maxRows; i++) {
|
|
528
|
+
const emptyLine = `${BOX.vertical}${" ".repeat(Math.max(0, width - 2))}${BOX.vertical}`;
|
|
529
|
+
output += `${CURSOR.cursorTo(startRow + 3 + i, 1)}${emptyLine}\n`;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Bottom border
|
|
533
|
+
const bottomBorder = horizontalLine(width, BOX.tee, BOX.horizontal, BOX.teeRight);
|
|
534
|
+
output += `${CURSOR.cursorTo(startRow + 3 + maxRows, 1)}${bottomBorder}\n`;
|
|
535
|
+
|
|
536
|
+
return output;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Get color for mail priority.
|
|
541
|
+
*/
|
|
542
|
+
function getPriorityColor(priority: string): string {
|
|
543
|
+
switch (priority) {
|
|
544
|
+
case "urgent":
|
|
545
|
+
return color.red;
|
|
546
|
+
case "high":
|
|
547
|
+
return color.yellow;
|
|
548
|
+
case "normal":
|
|
549
|
+
return color.white;
|
|
550
|
+
case "low":
|
|
551
|
+
return color.dim;
|
|
552
|
+
default:
|
|
553
|
+
return color.white;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Render the mail panel (middle-left ~30% height, ~60% width).
|
|
559
|
+
*/
|
|
560
|
+
function renderMailPanel(
|
|
561
|
+
data: DashboardData,
|
|
562
|
+
width: number,
|
|
563
|
+
height: number,
|
|
564
|
+
startRow: number,
|
|
565
|
+
): string {
|
|
566
|
+
const panelHeight = Math.floor(height * 0.3);
|
|
567
|
+
const panelWidth = Math.floor(width * 0.6);
|
|
568
|
+
let output = "";
|
|
569
|
+
|
|
570
|
+
const unreadCount = data.status.unreadMailCount;
|
|
571
|
+
const headerLine = `${BOX.vertical} ${color.bold}Mail${color.reset} (${unreadCount} unread)`;
|
|
572
|
+
const headerPadding = " ".repeat(
|
|
573
|
+
Math.max(0, panelWidth - headerLine.length - 1 + color.bold.length + color.reset.length),
|
|
574
|
+
);
|
|
575
|
+
output += `${CURSOR.cursorTo(startRow, 1)}${headerLine}${headerPadding}${BOX.vertical}\n`;
|
|
576
|
+
|
|
577
|
+
const separator = horizontalLine(panelWidth, BOX.tee, BOX.horizontal, BOX.cross);
|
|
578
|
+
output += `${CURSOR.cursorTo(startRow + 1, 1)}${separator}\n`;
|
|
579
|
+
|
|
580
|
+
const maxRows = panelHeight - 3; // header + separator + border
|
|
581
|
+
const messages = data.recentMail.slice(0, maxRows);
|
|
582
|
+
|
|
583
|
+
for (let i = 0; i < messages.length; i++) {
|
|
584
|
+
const msg = messages[i];
|
|
585
|
+
if (!msg) continue;
|
|
586
|
+
|
|
587
|
+
const priorityColor = getPriorityColor(msg.priority);
|
|
588
|
+
const priority = msg.priority === "normal" ? "" : `[${msg.priority}] `;
|
|
589
|
+
const from = truncate(msg.from, 12);
|
|
590
|
+
const to = truncate(msg.to, 12);
|
|
591
|
+
const subject = truncate(msg.subject, panelWidth - 40);
|
|
592
|
+
const time = timeAgo(msg.createdAt);
|
|
593
|
+
|
|
594
|
+
const line = `${BOX.vertical} ${priorityColor}${priority}${color.reset}${from} → ${to}: ${subject} (${time})`;
|
|
595
|
+
const padding = " ".repeat(
|
|
596
|
+
Math.max(
|
|
597
|
+
0,
|
|
598
|
+
panelWidth -
|
|
599
|
+
line.length -
|
|
600
|
+
1 +
|
|
601
|
+
priorityColor.length +
|
|
602
|
+
color.reset.length +
|
|
603
|
+
priorityColor.length +
|
|
604
|
+
color.reset.length,
|
|
605
|
+
),
|
|
606
|
+
);
|
|
607
|
+
output += `${CURSOR.cursorTo(startRow + 2 + i, 1)}${line}${padding}${BOX.vertical}\n`;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Fill remaining rows with empty lines
|
|
611
|
+
for (let i = messages.length; i < maxRows; i++) {
|
|
612
|
+
const emptyLine = `${BOX.vertical}${" ".repeat(Math.max(0, panelWidth - 2))}${BOX.vertical}`;
|
|
613
|
+
output += `${CURSOR.cursorTo(startRow + 2 + i, 1)}${emptyLine}\n`;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
return output;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Get color for merge queue status.
|
|
621
|
+
*/
|
|
622
|
+
function getMergeStatusColor(status: string): string {
|
|
623
|
+
switch (status) {
|
|
624
|
+
case "pending":
|
|
625
|
+
return color.yellow;
|
|
626
|
+
case "merging":
|
|
627
|
+
return color.blue;
|
|
628
|
+
case "conflict":
|
|
629
|
+
return color.red;
|
|
630
|
+
case "merged":
|
|
631
|
+
return color.green;
|
|
632
|
+
default:
|
|
633
|
+
return color.white;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Render the merge queue panel (middle-right ~30% height, ~40% width).
|
|
639
|
+
*/
|
|
640
|
+
function renderMergeQueuePanel(
|
|
641
|
+
data: DashboardData,
|
|
642
|
+
width: number,
|
|
643
|
+
height: number,
|
|
644
|
+
startRow: number,
|
|
645
|
+
startCol: number,
|
|
646
|
+
): string {
|
|
647
|
+
const panelHeight = Math.floor(height * 0.3);
|
|
648
|
+
const panelWidth = width - startCol + 1;
|
|
649
|
+
let output = "";
|
|
650
|
+
|
|
651
|
+
const headerLine = `${BOX.vertical} ${color.bold}Merge Queue${color.reset} (${data.mergeQueue.length})`;
|
|
652
|
+
const headerPadding = " ".repeat(
|
|
653
|
+
Math.max(0, panelWidth - headerLine.length - 1 + color.bold.length + color.reset.length),
|
|
654
|
+
);
|
|
655
|
+
output += `${CURSOR.cursorTo(startRow, startCol)}${headerLine}${headerPadding}${BOX.vertical}\n`;
|
|
656
|
+
|
|
657
|
+
const separator = horizontalLine(panelWidth, BOX.cross, BOX.horizontal, BOX.teeRight);
|
|
658
|
+
output += `${CURSOR.cursorTo(startRow + 1, startCol)}${separator}\n`;
|
|
659
|
+
|
|
660
|
+
const maxRows = panelHeight - 3; // header + separator + border
|
|
661
|
+
const entries = data.mergeQueue.slice(0, maxRows);
|
|
662
|
+
|
|
663
|
+
for (let i = 0; i < entries.length; i++) {
|
|
664
|
+
const entry = entries[i];
|
|
665
|
+
if (!entry) continue;
|
|
666
|
+
|
|
667
|
+
const statusColor = getMergeStatusColor(entry.status);
|
|
668
|
+
const status = pad(entry.status, 10);
|
|
669
|
+
const agent = truncate(entry.agentName, 15);
|
|
670
|
+
const branch = truncate(entry.branchName, panelWidth - 30);
|
|
671
|
+
|
|
672
|
+
const line = `${BOX.vertical} ${statusColor}${status}${color.reset} ${agent} ${branch}`;
|
|
673
|
+
const padding = " ".repeat(
|
|
674
|
+
Math.max(0, panelWidth - line.length - 1 + statusColor.length + color.reset.length),
|
|
675
|
+
);
|
|
676
|
+
output += `${CURSOR.cursorTo(startRow + 2 + i, startCol)}${line}${padding}${BOX.vertical}\n`;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Fill remaining rows with empty lines
|
|
680
|
+
for (let i = entries.length; i < maxRows; i++) {
|
|
681
|
+
const emptyLine = `${BOX.vertical}${" ".repeat(Math.max(0, panelWidth - 2))}${BOX.vertical}`;
|
|
682
|
+
output += `${CURSOR.cursorTo(startRow + 2 + i, startCol)}${emptyLine}\n`;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
return output;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Render the metrics panel (bottom strip).
|
|
690
|
+
*/
|
|
691
|
+
function renderMetricsPanel(
|
|
692
|
+
data: DashboardData,
|
|
693
|
+
width: number,
|
|
694
|
+
_height: number,
|
|
695
|
+
startRow: number,
|
|
696
|
+
): string {
|
|
697
|
+
let output = "";
|
|
698
|
+
|
|
699
|
+
const separator = horizontalLine(width, BOX.tee, BOX.horizontal, BOX.teeRight);
|
|
700
|
+
output += `${CURSOR.cursorTo(startRow, 1)}${separator}\n`;
|
|
701
|
+
|
|
702
|
+
const headerLine = `${BOX.vertical} ${color.bold}Metrics${color.reset}`;
|
|
703
|
+
const headerPadding = " ".repeat(
|
|
704
|
+
Math.max(0, width - headerLine.length - 1 + color.bold.length + color.reset.length),
|
|
705
|
+
);
|
|
706
|
+
output += `${CURSOR.cursorTo(startRow + 1, 1)}${headerLine}${headerPadding}${BOX.vertical}\n`;
|
|
707
|
+
|
|
708
|
+
const totalSessions = data.metrics.totalSessions;
|
|
709
|
+
const avgDuration = formatDuration(data.metrics.avgDuration);
|
|
710
|
+
const byCapability = Object.entries(data.metrics.byCapability)
|
|
711
|
+
.map(([cap, count]) => `${cap}:${count}`)
|
|
712
|
+
.join(", ");
|
|
713
|
+
|
|
714
|
+
const metricsLine = `${BOX.vertical} Total sessions: ${totalSessions} | Avg duration: ${avgDuration} | By capability: ${byCapability}`;
|
|
715
|
+
const metricsPadding = " ".repeat(Math.max(0, width - metricsLine.length - 1));
|
|
716
|
+
output += `${CURSOR.cursorTo(startRow + 2, 1)}${metricsLine}${metricsPadding}${BOX.vertical}\n`;
|
|
717
|
+
|
|
718
|
+
const bottomBorder = horizontalLine(width, BOX.bottomLeft, BOX.horizontal, BOX.bottomRight);
|
|
719
|
+
output += `${CURSOR.cursorTo(startRow + 3, 1)}${bottomBorder}\n`;
|
|
720
|
+
|
|
721
|
+
return output;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Render the full dashboard.
|
|
726
|
+
*/
|
|
727
|
+
function renderDashboard(data: DashboardData, interval: number): void {
|
|
728
|
+
const width = process.stdout.columns ?? 100;
|
|
729
|
+
const height = process.stdout.rows ?? 30;
|
|
730
|
+
|
|
731
|
+
let output = CURSOR.clear;
|
|
732
|
+
|
|
733
|
+
// Header (rows 1-2)
|
|
734
|
+
output += renderHeader(width, interval, data.currentRunId);
|
|
735
|
+
|
|
736
|
+
// Agent panel (rows 3 to ~40% of screen)
|
|
737
|
+
const agentPanelStart = 3;
|
|
738
|
+
output += renderAgentPanel(data, width, height, agentPanelStart);
|
|
739
|
+
|
|
740
|
+
// Calculate middle panels start row
|
|
741
|
+
const agentPanelHeight = Math.floor(height * 0.4);
|
|
742
|
+
const middlePanelStart = agentPanelStart + agentPanelHeight + 1;
|
|
743
|
+
|
|
744
|
+
// Mail panel (left 60%)
|
|
745
|
+
output += renderMailPanel(data, width, height, middlePanelStart);
|
|
746
|
+
|
|
747
|
+
// Merge queue panel (right 40%)
|
|
748
|
+
const mergeQueueCol = Math.floor(width * 0.6) + 1;
|
|
749
|
+
output += renderMergeQueuePanel(data, width, height, middlePanelStart, mergeQueueCol);
|
|
750
|
+
|
|
751
|
+
// Metrics panel (bottom strip)
|
|
752
|
+
const middlePanelHeight = Math.floor(height * 0.3);
|
|
753
|
+
const metricsStart = middlePanelStart + middlePanelHeight + 1;
|
|
754
|
+
output += renderMetricsPanel(data, width, height, metricsStart);
|
|
755
|
+
|
|
756
|
+
process.stdout.write(output);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Entry point for `overstory dashboard [--interval <ms>] [--all]`.
|
|
761
|
+
*/
|
|
762
|
+
const DASHBOARD_HELP = `overstory dashboard — Live TUI dashboard for agent monitoring
|
|
763
|
+
|
|
764
|
+
Usage: overstory dashboard [--interval <ms>] [--all]
|
|
765
|
+
|
|
766
|
+
Options:
|
|
767
|
+
--interval <ms> Poll interval in milliseconds (default: 2000, min: 500)
|
|
768
|
+
--all Show data from all runs (default: current run only)
|
|
769
|
+
--help, -h Show this help
|
|
770
|
+
|
|
771
|
+
Dashboard panels:
|
|
772
|
+
- Agent panel: Active agents with status, capability, bead ID, duration
|
|
773
|
+
- Mail panel: Recent messages with priority and time
|
|
774
|
+
- Merge queue: Pending/merging/conflict entries
|
|
775
|
+
- Metrics: Session counts, avg duration, by-capability breakdown
|
|
776
|
+
|
|
777
|
+
By default the dashboard scopes all panels to the current run (current-run.txt).
|
|
778
|
+
Use --all to see data across all runs.
|
|
779
|
+
|
|
780
|
+
Press Ctrl+C to exit.`;
|
|
781
|
+
|
|
782
|
+
export async function dashboardCommand(args: string[]): Promise<void> {
|
|
783
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
784
|
+
process.stdout.write(`${DASHBOARD_HELP}\n`);
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const intervalStr = getFlag(args, "--interval");
|
|
789
|
+
const interval = intervalStr ? Number.parseInt(intervalStr, 10) : 2000;
|
|
790
|
+
const showAll = args.includes("--all");
|
|
791
|
+
|
|
792
|
+
if (Number.isNaN(interval) || interval < 500) {
|
|
793
|
+
throw new ValidationError("--interval must be a number >= 500 (milliseconds)", {
|
|
794
|
+
field: "interval",
|
|
795
|
+
value: intervalStr,
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const cwd = process.cwd();
|
|
800
|
+
const config = await loadConfig(cwd);
|
|
801
|
+
const root = config.project.root;
|
|
802
|
+
|
|
803
|
+
// Read current run ID unless --all flag is set
|
|
804
|
+
let runId: string | null | undefined;
|
|
805
|
+
if (!showAll) {
|
|
806
|
+
const overstoryDir = join(root, ".overstory");
|
|
807
|
+
runId = await readCurrentRunId(overstoryDir);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Open stores once for the entire poll loop lifetime
|
|
811
|
+
const stores = openDashboardStores(root);
|
|
812
|
+
|
|
813
|
+
// Compute health thresholds once from config (reused across poll ticks)
|
|
814
|
+
const thresholds = {
|
|
815
|
+
staleMs: config.watchdog.staleThresholdMs,
|
|
816
|
+
zombieMs: config.watchdog.zombieThresholdMs,
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
// Hide cursor
|
|
820
|
+
process.stdout.write(CURSOR.hideCursor);
|
|
821
|
+
|
|
822
|
+
// Clean exit on Ctrl+C
|
|
823
|
+
let running = true;
|
|
824
|
+
process.on("SIGINT", () => {
|
|
825
|
+
running = false;
|
|
826
|
+
closeDashboardStores(stores);
|
|
827
|
+
process.stdout.write(CURSOR.showCursor);
|
|
828
|
+
process.stdout.write(CURSOR.clear);
|
|
829
|
+
process.exit(0);
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
// Poll loop
|
|
833
|
+
while (running) {
|
|
834
|
+
const data = await loadDashboardData(root, stores, runId, thresholds);
|
|
835
|
+
renderDashboard(data, interval);
|
|
836
|
+
await Bun.sleep(interval);
|
|
837
|
+
}
|
|
838
|
+
}
|