@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.
Files changed (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +381 -0
  3. package/agents/builder.md +137 -0
  4. package/agents/coordinator.md +263 -0
  5. package/agents/lead.md +301 -0
  6. package/agents/merger.md +160 -0
  7. package/agents/monitor.md +214 -0
  8. package/agents/reviewer.md +140 -0
  9. package/agents/scout.md +119 -0
  10. package/agents/supervisor.md +423 -0
  11. package/package.json +47 -0
  12. package/src/agents/checkpoint.test.ts +88 -0
  13. package/src/agents/checkpoint.ts +101 -0
  14. package/src/agents/hooks-deployer.test.ts +2040 -0
  15. package/src/agents/hooks-deployer.ts +607 -0
  16. package/src/agents/identity.test.ts +603 -0
  17. package/src/agents/identity.ts +384 -0
  18. package/src/agents/lifecycle.test.ts +196 -0
  19. package/src/agents/lifecycle.ts +183 -0
  20. package/src/agents/manifest.test.ts +746 -0
  21. package/src/agents/manifest.ts +354 -0
  22. package/src/agents/overlay.test.ts +676 -0
  23. package/src/agents/overlay.ts +308 -0
  24. package/src/beads/client.test.ts +217 -0
  25. package/src/beads/client.ts +202 -0
  26. package/src/beads/molecules.test.ts +338 -0
  27. package/src/beads/molecules.ts +198 -0
  28. package/src/commands/agents.test.ts +322 -0
  29. package/src/commands/agents.ts +287 -0
  30. package/src/commands/clean.test.ts +670 -0
  31. package/src/commands/clean.ts +618 -0
  32. package/src/commands/completions.test.ts +342 -0
  33. package/src/commands/completions.ts +887 -0
  34. package/src/commands/coordinator.test.ts +1530 -0
  35. package/src/commands/coordinator.ts +733 -0
  36. package/src/commands/costs.test.ts +1119 -0
  37. package/src/commands/costs.ts +564 -0
  38. package/src/commands/dashboard.test.ts +308 -0
  39. package/src/commands/dashboard.ts +838 -0
  40. package/src/commands/doctor.test.ts +294 -0
  41. package/src/commands/doctor.ts +213 -0
  42. package/src/commands/errors.test.ts +647 -0
  43. package/src/commands/errors.ts +248 -0
  44. package/src/commands/feed.test.ts +578 -0
  45. package/src/commands/feed.ts +361 -0
  46. package/src/commands/group.test.ts +262 -0
  47. package/src/commands/group.ts +511 -0
  48. package/src/commands/hooks.test.ts +458 -0
  49. package/src/commands/hooks.ts +253 -0
  50. package/src/commands/init.test.ts +347 -0
  51. package/src/commands/init.ts +650 -0
  52. package/src/commands/inspect.test.ts +670 -0
  53. package/src/commands/inspect.ts +431 -0
  54. package/src/commands/log.test.ts +1454 -0
  55. package/src/commands/log.ts +724 -0
  56. package/src/commands/logs.test.ts +379 -0
  57. package/src/commands/logs.ts +546 -0
  58. package/src/commands/mail.test.ts +1270 -0
  59. package/src/commands/mail.ts +771 -0
  60. package/src/commands/merge.test.ts +670 -0
  61. package/src/commands/merge.ts +355 -0
  62. package/src/commands/metrics.test.ts +444 -0
  63. package/src/commands/metrics.ts +143 -0
  64. package/src/commands/monitor.test.ts +191 -0
  65. package/src/commands/monitor.ts +390 -0
  66. package/src/commands/nudge.test.ts +230 -0
  67. package/src/commands/nudge.ts +372 -0
  68. package/src/commands/prime.test.ts +470 -0
  69. package/src/commands/prime.ts +381 -0
  70. package/src/commands/replay.test.ts +741 -0
  71. package/src/commands/replay.ts +360 -0
  72. package/src/commands/run.test.ts +431 -0
  73. package/src/commands/run.ts +351 -0
  74. package/src/commands/sling.test.ts +657 -0
  75. package/src/commands/sling.ts +661 -0
  76. package/src/commands/spec.test.ts +203 -0
  77. package/src/commands/spec.ts +168 -0
  78. package/src/commands/status.test.ts +430 -0
  79. package/src/commands/status.ts +398 -0
  80. package/src/commands/stop.test.ts +420 -0
  81. package/src/commands/stop.ts +151 -0
  82. package/src/commands/supervisor.test.ts +187 -0
  83. package/src/commands/supervisor.ts +535 -0
  84. package/src/commands/trace.test.ts +745 -0
  85. package/src/commands/trace.ts +325 -0
  86. package/src/commands/watch.test.ts +145 -0
  87. package/src/commands/watch.ts +247 -0
  88. package/src/commands/worktree.test.ts +786 -0
  89. package/src/commands/worktree.ts +311 -0
  90. package/src/config.test.ts +822 -0
  91. package/src/config.ts +829 -0
  92. package/src/doctor/agents.test.ts +454 -0
  93. package/src/doctor/agents.ts +396 -0
  94. package/src/doctor/config-check.test.ts +190 -0
  95. package/src/doctor/config-check.ts +183 -0
  96. package/src/doctor/consistency.test.ts +651 -0
  97. package/src/doctor/consistency.ts +294 -0
  98. package/src/doctor/databases.test.ts +290 -0
  99. package/src/doctor/databases.ts +218 -0
  100. package/src/doctor/dependencies.test.ts +184 -0
  101. package/src/doctor/dependencies.ts +175 -0
  102. package/src/doctor/logs.test.ts +251 -0
  103. package/src/doctor/logs.ts +295 -0
  104. package/src/doctor/merge-queue.test.ts +216 -0
  105. package/src/doctor/merge-queue.ts +144 -0
  106. package/src/doctor/structure.test.ts +291 -0
  107. package/src/doctor/structure.ts +198 -0
  108. package/src/doctor/types.ts +37 -0
  109. package/src/doctor/version.test.ts +136 -0
  110. package/src/doctor/version.ts +129 -0
  111. package/src/e2e/init-sling-lifecycle.test.ts +277 -0
  112. package/src/errors.ts +217 -0
  113. package/src/events/store.test.ts +660 -0
  114. package/src/events/store.ts +369 -0
  115. package/src/events/tool-filter.test.ts +330 -0
  116. package/src/events/tool-filter.ts +126 -0
  117. package/src/index.ts +316 -0
  118. package/src/insights/analyzer.test.ts +466 -0
  119. package/src/insights/analyzer.ts +203 -0
  120. package/src/logging/color.test.ts +142 -0
  121. package/src/logging/color.ts +71 -0
  122. package/src/logging/logger.test.ts +813 -0
  123. package/src/logging/logger.ts +266 -0
  124. package/src/logging/reporter.test.ts +259 -0
  125. package/src/logging/reporter.ts +109 -0
  126. package/src/logging/sanitizer.test.ts +190 -0
  127. package/src/logging/sanitizer.ts +57 -0
  128. package/src/mail/broadcast.test.ts +203 -0
  129. package/src/mail/broadcast.ts +92 -0
  130. package/src/mail/client.test.ts +773 -0
  131. package/src/mail/client.ts +223 -0
  132. package/src/mail/store.test.ts +705 -0
  133. package/src/mail/store.ts +387 -0
  134. package/src/merge/queue.test.ts +359 -0
  135. package/src/merge/queue.ts +231 -0
  136. package/src/merge/resolver.test.ts +1345 -0
  137. package/src/merge/resolver.ts +645 -0
  138. package/src/metrics/store.test.ts +667 -0
  139. package/src/metrics/store.ts +445 -0
  140. package/src/metrics/summary.test.ts +398 -0
  141. package/src/metrics/summary.ts +178 -0
  142. package/src/metrics/transcript.test.ts +356 -0
  143. package/src/metrics/transcript.ts +175 -0
  144. package/src/mulch/client.test.ts +671 -0
  145. package/src/mulch/client.ts +332 -0
  146. package/src/sessions/compat.test.ts +280 -0
  147. package/src/sessions/compat.ts +104 -0
  148. package/src/sessions/store.test.ts +873 -0
  149. package/src/sessions/store.ts +494 -0
  150. package/src/test-helpers.test.ts +124 -0
  151. package/src/test-helpers.ts +126 -0
  152. package/src/tracker/beads.ts +56 -0
  153. package/src/tracker/factory.test.ts +80 -0
  154. package/src/tracker/factory.ts +64 -0
  155. package/src/tracker/seeds.ts +182 -0
  156. package/src/tracker/types.ts +52 -0
  157. package/src/types.ts +724 -0
  158. package/src/watchdog/daemon.test.ts +1975 -0
  159. package/src/watchdog/daemon.ts +671 -0
  160. package/src/watchdog/health.test.ts +431 -0
  161. package/src/watchdog/health.ts +264 -0
  162. package/src/watchdog/triage.test.ts +164 -0
  163. package/src/watchdog/triage.ts +179 -0
  164. package/src/worktree/manager.test.ts +439 -0
  165. package/src/worktree/manager.ts +198 -0
  166. package/src/worktree/tmux.test.ts +1009 -0
  167. package/src/worktree/tmux.ts +509 -0
  168. package/templates/CLAUDE.md.tmpl +89 -0
  169. package/templates/hooks.json.tmpl +105 -0
  170. 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
+ }