@ekkos/cli 0.2.18 → 1.0.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.
Files changed (98) hide show
  1. package/README.md +57 -0
  2. package/dist/agent/daemon.d.ts +27 -0
  3. package/dist/agent/daemon.js +254 -29
  4. package/dist/agent/health-check.d.ts +35 -0
  5. package/dist/agent/health-check.js +243 -0
  6. package/dist/agent/pty-runner.d.ts +1 -0
  7. package/dist/agent/pty-runner.js +6 -1
  8. package/dist/capture/eviction-client.d.ts +139 -0
  9. package/dist/capture/eviction-client.js +454 -0
  10. package/dist/capture/index.d.ts +2 -0
  11. package/dist/capture/index.js +2 -0
  12. package/dist/capture/jsonl-rewriter.d.ts +96 -0
  13. package/dist/capture/jsonl-rewriter.js +1369 -0
  14. package/dist/capture/transcript-repair.d.ts +51 -0
  15. package/dist/capture/transcript-repair.js +319 -0
  16. package/dist/commands/agent.d.ts +6 -0
  17. package/dist/commands/agent.js +244 -0
  18. package/dist/commands/dashboard.d.ts +25 -0
  19. package/dist/commands/dashboard.js +1175 -0
  20. package/dist/commands/doctor.js +23 -1
  21. package/dist/commands/run.d.ts +5 -0
  22. package/dist/commands/run.js +1605 -516
  23. package/dist/commands/setup-remote.js +146 -37
  24. package/dist/commands/swarm-dashboard.d.ts +20 -0
  25. package/dist/commands/swarm-dashboard.js +735 -0
  26. package/dist/commands/swarm-setup.d.ts +10 -0
  27. package/dist/commands/swarm-setup.js +956 -0
  28. package/dist/commands/swarm.d.ts +46 -0
  29. package/dist/commands/swarm.js +441 -0
  30. package/dist/commands/test-claude.d.ts +16 -0
  31. package/dist/commands/test-claude.js +156 -0
  32. package/dist/commands/usage/blocks.d.ts +8 -0
  33. package/dist/commands/usage/blocks.js +60 -0
  34. package/dist/commands/usage/daily.d.ts +9 -0
  35. package/dist/commands/usage/daily.js +96 -0
  36. package/dist/commands/usage/dashboard.d.ts +8 -0
  37. package/dist/commands/usage/dashboard.js +104 -0
  38. package/dist/commands/usage/formatters.d.ts +41 -0
  39. package/dist/commands/usage/formatters.js +147 -0
  40. package/dist/commands/usage/index.d.ts +13 -0
  41. package/dist/commands/usage/index.js +87 -0
  42. package/dist/commands/usage/monthly.d.ts +8 -0
  43. package/dist/commands/usage/monthly.js +66 -0
  44. package/dist/commands/usage/session.d.ts +11 -0
  45. package/dist/commands/usage/session.js +193 -0
  46. package/dist/commands/usage/weekly.d.ts +9 -0
  47. package/dist/commands/usage/weekly.js +61 -0
  48. package/dist/commands/usage.d.ts +7 -0
  49. package/dist/commands/usage.js +214 -0
  50. package/dist/cron/index.d.ts +7 -0
  51. package/dist/cron/index.js +13 -0
  52. package/dist/cron/promoter.d.ts +70 -0
  53. package/dist/cron/promoter.js +403 -0
  54. package/dist/deploy/instructions.d.ts +5 -2
  55. package/dist/deploy/instructions.js +11 -8
  56. package/dist/index.js +262 -5
  57. package/dist/lib/tmux-scrollbar.d.ts +14 -0
  58. package/dist/lib/tmux-scrollbar.js +296 -0
  59. package/dist/lib/usage-monitor.d.ts +47 -0
  60. package/dist/lib/usage-monitor.js +124 -0
  61. package/dist/lib/usage-parser.d.ts +162 -0
  62. package/dist/lib/usage-parser.js +583 -0
  63. package/dist/restore/RestoreOrchestrator.d.ts +4 -0
  64. package/dist/restore/RestoreOrchestrator.js +118 -30
  65. package/dist/utils/log-rotate.d.ts +18 -0
  66. package/dist/utils/log-rotate.js +74 -0
  67. package/dist/utils/platform.d.ts +2 -0
  68. package/dist/utils/platform.js +3 -1
  69. package/dist/utils/session-binding.d.ts +5 -0
  70. package/dist/utils/session-binding.js +46 -0
  71. package/dist/utils/state.js +4 -0
  72. package/dist/utils/verify-remote-terminal.d.ts +10 -0
  73. package/dist/utils/verify-remote-terminal.js +415 -0
  74. package/package.json +9 -2
  75. package/templates/CLAUDE.md +135 -23
  76. package/templates/ekkos-manifest.json +5 -5
  77. package/templates/hooks/lib/contract.sh +43 -31
  78. package/templates/hooks/lib/count-tokens.cjs +86 -0
  79. package/templates/hooks/lib/ekkos-reminders.sh +98 -0
  80. package/templates/hooks/lib/state.sh +53 -1
  81. package/templates/hooks/stop.sh +150 -388
  82. package/templates/hooks/user-prompt-submit.sh +353 -443
  83. package/templates/windsurf-hooks/README.md +212 -0
  84. package/templates/windsurf-hooks/hooks.json +9 -2
  85. package/templates/windsurf-hooks/install.sh +148 -0
  86. package/templates/windsurf-hooks/lib/contract.sh +2 -0
  87. package/templates/windsurf-hooks/post-cascade-response.sh +251 -0
  88. package/templates/windsurf-hooks/pre-user-prompt.sh +435 -0
  89. package/templates/windsurf-skills/ekkos-memory/SKILL.md +219 -0
  90. package/templates/agents/README.md +0 -182
  91. package/templates/agents/code-reviewer.md +0 -166
  92. package/templates/agents/debug-detective.md +0 -169
  93. package/templates/agents/ekkOS_Vercel.md +0 -99
  94. package/templates/agents/extension-manager.md +0 -229
  95. package/templates/agents/git-companion.md +0 -185
  96. package/templates/agents/github-test-agent.md +0 -321
  97. package/templates/agents/railway-manager.md +0 -215
  98. package/templates/windsurf-hooks/before-submit-prompt.sh +0 -238
@@ -0,0 +1,735 @@
1
+ "use strict";
2
+ /**
3
+ * ekkos swarm dashboard
4
+ *
5
+ * Live TUI dashboard for monitoring a swarm of parallel Claude Code workers.
6
+ * Uses blessed-contrib for rich terminal widgets.
7
+ *
8
+ * Layout:
9
+ * Header: Swarm name + worker count + total cost + duration
10
+ * Workers: Per-worker status cards (context bar, cost, turns, model)
11
+ * Chart: Combined token usage from all workers
12
+ * Table: All turns from all workers (with worker column)
13
+ * Usage: Anthropic rate limit windows
14
+ * Footer: Aggregate totals + routing breakdown + keybindings
15
+ *
16
+ * Usage:
17
+ * ekkos swarm dashboard Auto-detect active swarm
18
+ * ekkos swarm dashboard --launch-ts 123 Use specific launch timestamp
19
+ */
20
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
21
+ if (k2 === undefined) k2 = k;
22
+ var desc = Object.getOwnPropertyDescriptor(m, k);
23
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
24
+ desc = { enumerable: true, get: function() { return m[k]; } };
25
+ }
26
+ Object.defineProperty(o, k2, desc);
27
+ }) : (function(o, m, k, k2) {
28
+ if (k2 === undefined) k2 = k;
29
+ o[k2] = m[k];
30
+ }));
31
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
32
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
33
+ }) : function(o, v) {
34
+ o["default"] = v;
35
+ });
36
+ var __importStar = (this && this.__importStar) || (function () {
37
+ var ownKeys = function(o) {
38
+ ownKeys = Object.getOwnPropertyNames || function (o) {
39
+ var ar = [];
40
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
41
+ return ar;
42
+ };
43
+ return ownKeys(o);
44
+ };
45
+ return function (mod) {
46
+ if (mod && mod.__esModule) return mod;
47
+ var result = {};
48
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
49
+ __setModuleDefault(result, mod);
50
+ return result;
51
+ };
52
+ })();
53
+ var __importDefault = (this && this.__importDefault) || function (mod) {
54
+ return (mod && mod.__esModule) ? mod : { "default": mod };
55
+ };
56
+ Object.defineProperty(exports, "__esModule", { value: true });
57
+ exports.swarmDashboardCommand = void 0;
58
+ const fs = __importStar(require("fs"));
59
+ const os = __importStar(require("os"));
60
+ const path = __importStar(require("path"));
61
+ const chalk_1 = __importDefault(require("chalk"));
62
+ const commander_1 = require("commander");
63
+ const state_js_1 = require("../utils/state.js");
64
+ // ── Pricing (per MTok) ──
65
+ const MODEL_PRICING = {
66
+ 'claude-opus-4-6': { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.50 },
67
+ 'claude-opus-4-5-20250620': { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.50 },
68
+ 'claude-sonnet-4-5-20250929': { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.30 },
69
+ 'claude-sonnet-4-5-20250514': { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.30 },
70
+ 'claude-haiku-4-5-20251001': { input: 0.80, output: 4, cacheWrite: 1.00, cacheRead: 0.08 },
71
+ };
72
+ function getModelPricing(modelId) {
73
+ if (MODEL_PRICING[modelId])
74
+ return MODEL_PRICING[modelId];
75
+ if (modelId.includes('opus'))
76
+ return MODEL_PRICING['claude-opus-4-6'];
77
+ if (modelId.includes('sonnet'))
78
+ return MODEL_PRICING['claude-sonnet-4-5-20250929'];
79
+ if (modelId.includes('haiku'))
80
+ return MODEL_PRICING['claude-haiku-4-5-20251001'];
81
+ return MODEL_PRICING['claude-sonnet-4-5-20250929'];
82
+ }
83
+ function calculateTurnCost(model, usage) {
84
+ const p = getModelPricing(model);
85
+ return ((usage.input_tokens / 1000000) * p.input +
86
+ (usage.output_tokens / 1000000) * p.output +
87
+ (usage.cache_creation_tokens / 1000000) * p.cacheWrite +
88
+ (usage.cache_read_tokens / 1000000) * p.cacheRead);
89
+ }
90
+ function getModelCtxSize(model) {
91
+ return 200000; // All current Claude models
92
+ }
93
+ function modelTag(model) {
94
+ if (model.includes('opus'))
95
+ return 'Opus';
96
+ if (model.includes('sonnet'))
97
+ return 'Sonnet';
98
+ if (model.includes('haiku'))
99
+ return 'Haiku';
100
+ return '?';
101
+ }
102
+ function parseWorkerJsonl(jsonlPath, workerIndex, sessionName) {
103
+ const content = fs.readFileSync(jsonlPath, 'utf-8');
104
+ const lines = content.trim().split('\n');
105
+ const turnsByMsgId = new Map();
106
+ const msgIdOrder = [];
107
+ let model = 'unknown';
108
+ const toolsByMessage = new Map();
109
+ for (const line of lines) {
110
+ try {
111
+ const entry = JSON.parse(line);
112
+ if (entry.type === 'assistant' && entry.message?.content) {
113
+ const msgId = entry.message.id;
114
+ for (const block of entry.message.content) {
115
+ if (block.type === 'tool_use' && block.name) {
116
+ if (!toolsByMessage.has(msgId))
117
+ toolsByMessage.set(msgId, new Set());
118
+ toolsByMessage.get(msgId).add(block.name);
119
+ }
120
+ }
121
+ }
122
+ if (entry.type === 'assistant' && entry.message?.usage) {
123
+ const msgId = entry.message.id;
124
+ const isNew = msgId && !turnsByMsgId.has(msgId);
125
+ const usage = entry.message.usage;
126
+ model = entry.message.model || model;
127
+ const routedModel = entry.message._ekkos_routed_model || entry.message.model || model;
128
+ const inputTokens = usage.input_tokens || 0;
129
+ const outputTokens = usage.output_tokens || 0;
130
+ const cacheReadTokens = usage.cache_read_input_tokens || 0;
131
+ const cacheCreationTokens = usage.cache_creation_input_tokens || 0;
132
+ const contextTokens = inputTokens + cacheReadTokens + cacheCreationTokens;
133
+ const modelCtxSize = getModelCtxSize(model);
134
+ const contextPct = (contextTokens / modelCtxSize) * 100;
135
+ const ts = entry.timestamp || new Date().toISOString();
136
+ const usageData = {
137
+ input_tokens: inputTokens,
138
+ output_tokens: outputTokens,
139
+ cache_read_tokens: cacheReadTokens,
140
+ cache_creation_tokens: cacheCreationTokens,
141
+ };
142
+ const turnCost = calculateTurnCost(routedModel, usageData);
143
+ const opusCost = routedModel !== model
144
+ ? calculateTurnCost(model, usageData)
145
+ : turnCost;
146
+ const savings = opusCost - turnCost;
147
+ const msgTools = toolsByMessage.get(msgId);
148
+ const toolStr = msgTools && msgTools.size > 0
149
+ ? Array.from(msgTools).map(t => t.replace(/^mcp__ekkos-memory__/, '').replace(/^ekkOS_/, '')).join(',')
150
+ : '-';
151
+ const turnNum = isNew ? msgIdOrder.length + 1 : (turnsByMsgId.get(msgId).turn);
152
+ const turnData = {
153
+ turn: turnNum, contextPct, cacheRead: cacheReadTokens,
154
+ cacheCreate: cacheCreationTokens, output: outputTokens,
155
+ cost: turnCost, opusCost, savings, tools: toolStr,
156
+ model, routedModel, timestamp: ts,
157
+ };
158
+ if (msgId) {
159
+ if (isNew)
160
+ msgIdOrder.push(msgId);
161
+ turnsByMsgId.set(msgId, turnData);
162
+ }
163
+ }
164
+ }
165
+ catch { /* skip bad lines */ }
166
+ }
167
+ const turns = msgIdOrder.map(id => turnsByMsgId.get(id));
168
+ const totalCost = turns.reduce((s, t) => s + t.cost, 0);
169
+ const totalCacheRead = turns.reduce((s, t) => s + t.cacheRead, 0);
170
+ const totalCacheCreate = turns.reduce((s, t) => s + t.cacheCreate, 0);
171
+ const totalOutput = turns.reduce((s, t) => s + t.output, 0);
172
+ const currentContextPct = turns.length > 0 ? turns[turns.length - 1].contextPct : 0;
173
+ const cacheHitRate = (totalCacheRead + totalCacheCreate) > 0
174
+ ? (totalCacheRead / (totalCacheRead + totalCacheCreate)) * 100 : 0;
175
+ const lastActivity = turns.length > 0 ? turns[turns.length - 1].timestamp : '';
176
+ return {
177
+ workerIndex,
178
+ sessionName,
179
+ jsonlPath,
180
+ model,
181
+ turnCount: turns.length,
182
+ totalCost,
183
+ totalTokens: totalCacheRead + totalCacheCreate + totalOutput,
184
+ totalCacheRead,
185
+ totalCacheCreate,
186
+ totalOutput,
187
+ currentContextPct,
188
+ cacheHitRate,
189
+ turns,
190
+ lastActivity,
191
+ };
192
+ }
193
+ // ── Worker discovery ──
194
+ function resolveJsonlPath(sessionName, createdAfterMs) {
195
+ const activeSessionsPath = path.join(os.homedir(), '.ekkos', 'active-sessions.json');
196
+ if (fs.existsSync(activeSessionsPath)) {
197
+ try {
198
+ const sessions = JSON.parse(fs.readFileSync(activeSessionsPath, 'utf-8'));
199
+ const match = sessions.find((s) => s.sessionName === sessionName);
200
+ if (match?.projectPath) {
201
+ return findLatestJsonl(match.projectPath, createdAfterMs);
202
+ }
203
+ }
204
+ catch { /* ignore */ }
205
+ }
206
+ return null;
207
+ }
208
+ function findLatestJsonl(projectPath, createdAfterMs) {
209
+ const encoded = projectPath.replace(/\//g, '-');
210
+ const projectDir = path.join(os.homedir(), '.claude', 'projects', encoded);
211
+ if (!fs.existsSync(projectDir))
212
+ return null;
213
+ const jsonlFiles = fs.readdirSync(projectDir)
214
+ .filter(f => f.endsWith('.jsonl'))
215
+ .map(f => {
216
+ const stat = fs.statSync(path.join(projectDir, f));
217
+ return { path: path.join(projectDir, f), mtime: stat.mtimeMs, birthtime: stat.birthtimeMs };
218
+ })
219
+ .filter(f => !createdAfterMs || f.birthtime > createdAfterMs)
220
+ .sort((a, b) => b.mtime - a.mtime);
221
+ return jsonlFiles.length > 0 ? jsonlFiles[0].path : null;
222
+ }
223
+ function discoverWorkers(launchTs) {
224
+ const sessions = (0, state_js_1.getActiveSessions)();
225
+ const workers = [];
226
+ // Find sessions started after swarm launch
227
+ const candidates = sessions
228
+ .filter(s => {
229
+ const startedMs = new Date(s.startedAt).getTime();
230
+ return startedMs >= launchTs - 5000; // 5s grace period
231
+ })
232
+ .sort((a, b) => new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime());
233
+ for (let i = 0; i < candidates.length; i++) {
234
+ const session = candidates[i];
235
+ const jsonlPath = resolveJsonlPath(session.sessionName, launchTs - 5000);
236
+ if (jsonlPath) {
237
+ try {
238
+ const data = parseWorkerJsonl(jsonlPath, i + 1, session.sessionName);
239
+ workers.push(data);
240
+ }
241
+ catch { /* skip broken jsonl */ }
242
+ }
243
+ else {
244
+ // Worker exists but no JSONL yet
245
+ workers.push({
246
+ workerIndex: i + 1,
247
+ sessionName: session.sessionName,
248
+ jsonlPath: '',
249
+ model: 'initializing',
250
+ turnCount: 0,
251
+ totalCost: 0,
252
+ totalTokens: 0,
253
+ totalCacheRead: 0,
254
+ totalCacheCreate: 0,
255
+ totalOutput: 0,
256
+ currentContextPct: 0,
257
+ cacheHitRate: 0,
258
+ turns: [],
259
+ lastActivity: '',
260
+ });
261
+ }
262
+ }
263
+ return workers;
264
+ }
265
+ function readSwarmMeta() {
266
+ const metaPath = path.join(process.cwd(), '.swarm', 'active-swarm.json');
267
+ try {
268
+ if (fs.existsSync(metaPath)) {
269
+ return JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
270
+ }
271
+ }
272
+ catch { }
273
+ return null;
274
+ }
275
+ // ── Helpers ──
276
+ function fmtK(n) {
277
+ if (n >= 1000000)
278
+ return `${(n / 1000000).toFixed(1)}M`;
279
+ if (n >= 1000)
280
+ return `${(n / 1000).toFixed(1)}K`;
281
+ return String(n);
282
+ }
283
+ function sleep(ms) {
284
+ return new Promise(resolve => setTimeout(resolve, ms));
285
+ }
286
+ // ── TUI Dashboard ──
287
+ async function launchSwarmDashboard(launchTs, refreshMs) {
288
+ const meta = readSwarmMeta();
289
+ const expectedWorkers = meta?.workers || 0;
290
+ const taskStr = meta?.task || 'Unknown task';
291
+ const blessed = require('blessed');
292
+ const contrib = require('blessed-contrib');
293
+ // Open /dev/tty for isolated terminal access (same as regular dashboard)
294
+ let ttyInput = process.stdin;
295
+ let ttyOutput = process.stdout;
296
+ try {
297
+ const ttyFd = fs.openSync('/dev/tty', 'r+');
298
+ ttyInput = new (require('tty').ReadStream)(ttyFd);
299
+ ttyOutput = new (require('tty').WriteStream)(ttyFd);
300
+ }
301
+ catch {
302
+ ttyInput = process.stdin;
303
+ ttyOutput = process.stdout;
304
+ }
305
+ const screen = blessed.screen({
306
+ smartCSR: true,
307
+ title: 'ekkOS Swarm',
308
+ fullUnicode: true,
309
+ mouse: false,
310
+ grabKeys: false,
311
+ sendFocus: false,
312
+ ignoreLocked: ['C-c'],
313
+ input: ttyInput,
314
+ output: ttyOutput,
315
+ forceUnicode: true,
316
+ terminal: 'xterm-256color',
317
+ resizeTimeout: 300,
318
+ });
319
+ // Disable terminal control sequence hijacking
320
+ if (screen.program) {
321
+ screen.program.alternateBuffer = () => { };
322
+ screen.program.normalBuffer = () => { };
323
+ if (screen.program.setRawMode) {
324
+ screen.program.setRawMode = (enabled) => enabled;
325
+ }
326
+ }
327
+ // ── Layout calculation ──
328
+ const W = '100%';
329
+ const HEADER_H = 3;
330
+ const WORKERS_H = 5;
331
+ const CHART_H_MIN = 6;
332
+ const USAGE_H = 4;
333
+ const FOOTER_H = 3;
334
+ const FIXED_H = HEADER_H + WORKERS_H + USAGE_H + FOOTER_H;
335
+ function calcLayout() {
336
+ const H = Math.max(30, screen.height);
337
+ const remaining = Math.max(10, H - FIXED_H);
338
+ const chartH = Math.max(CHART_H_MIN, Math.floor(remaining * 0.30));
339
+ const tableH = Math.max(4, remaining - chartH);
340
+ return {
341
+ header: { top: 0, height: HEADER_H },
342
+ workers: { top: HEADER_H, height: WORKERS_H },
343
+ chart: { top: HEADER_H + WORKERS_H, height: Math.max(CHART_H_MIN, chartH) },
344
+ table: { top: HEADER_H + WORKERS_H + chartH, height: Math.max(4, tableH) },
345
+ usage: { top: Math.max(HEADER_H + WORKERS_H + chartH + tableH, H - USAGE_H - FOOTER_H), height: USAGE_H },
346
+ footer: { top: Math.max(HEADER_H + WORKERS_H + chartH + tableH + USAGE_H, H - FOOTER_H), height: FOOTER_H },
347
+ };
348
+ }
349
+ let layout = calcLayout();
350
+ // ── Logo animation ──
351
+ const LOGO_CHARS = ['e', 'k', 'k', 'O', 'S', '_'];
352
+ const WAVE_COLORS = ['cyan', 'blue', 'magenta', 'yellow', 'green', 'white'];
353
+ let waveOffset = 0;
354
+ // ── Widgets ──
355
+ const headerBox = blessed.box({
356
+ top: layout.header.top, left: 0, width: W, height: layout.header.height,
357
+ content: ' {gray-fg}Starting swarm...{/gray-fg}',
358
+ tags: true,
359
+ style: { fg: 'white', border: { fg: 'yellow' } },
360
+ border: { type: 'line' },
361
+ label: ' ekkOS_ Swarm ',
362
+ });
363
+ const workersBox = blessed.box({
364
+ top: layout.workers.top, left: 0, width: W, height: layout.workers.height,
365
+ content: ' {gray-fg}Discovering workers...{/gray-fg}',
366
+ tags: true,
367
+ style: { fg: 'white', border: { fg: 'yellow' } },
368
+ border: { type: 'line' },
369
+ label: ' Workers ',
370
+ });
371
+ const tokenChart = contrib.line({
372
+ top: layout.chart.top, left: 0, width: W, height: layout.chart.height,
373
+ label: ' Tokens/Turn (K) ',
374
+ showLegend: true,
375
+ legend: { width: 12 },
376
+ style: { line: 'green', text: 'white', baseline: 'white', border: { fg: 'yellow' } },
377
+ border: { type: 'line', fg: 'yellow' },
378
+ xLabelPadding: 0, xPadding: 1, wholeNumbersOnly: false,
379
+ });
380
+ const turnBox = blessed.box({
381
+ top: layout.table.top, left: 0, width: W, height: layout.table.height,
382
+ content: '',
383
+ tags: true,
384
+ scrollable: true, alwaysScroll: true,
385
+ scrollbar: { ch: '|', style: { fg: 'yellow' } },
386
+ keys: true, vi: true, mouse: false,
387
+ input: true, interactive: true,
388
+ label: ' All Turns ',
389
+ border: { type: 'line', fg: 'yellow' },
390
+ style: { fg: 'white', border: { fg: 'yellow' } },
391
+ });
392
+ const windowBox = blessed.box({
393
+ top: layout.usage.top, left: 0, width: W, height: layout.usage.height,
394
+ content: ' {gray-fg}Loading usage...{/gray-fg}',
395
+ tags: true,
396
+ style: { fg: 'white', border: { fg: 'magenta' } },
397
+ border: { type: 'line' },
398
+ label: ' Rate Limit ',
399
+ });
400
+ const footerBox = blessed.box({
401
+ top: layout.footer.top, left: 0, width: W, height: layout.footer.height,
402
+ content: ' {gray-fg}Loading...{/gray-fg}',
403
+ tags: true,
404
+ style: { fg: 'white', border: { fg: 'yellow' } },
405
+ border: { type: 'line' },
406
+ label: ' Swarm Totals ',
407
+ });
408
+ screen.append(headerBox);
409
+ screen.append(workersBox);
410
+ screen.append(tokenChart);
411
+ screen.append(turnBox);
412
+ screen.append(windowBox);
413
+ screen.append(footerBox);
414
+ function applyLayout() {
415
+ layout = calcLayout();
416
+ headerBox.top = layout.header.top;
417
+ headerBox.height = layout.header.height;
418
+ workersBox.top = layout.workers.top;
419
+ workersBox.height = layout.workers.height;
420
+ tokenChart.top = layout.chart.top;
421
+ tokenChart.height = layout.chart.height;
422
+ turnBox.top = layout.table.top;
423
+ turnBox.height = layout.table.height;
424
+ windowBox.top = layout.usage.top;
425
+ windowBox.height = layout.usage.height;
426
+ footerBox.top = layout.footer.top;
427
+ footerBox.height = layout.footer.height;
428
+ }
429
+ // ── State ──
430
+ let lastWorkers = [];
431
+ let sparkleTimer;
432
+ // ── Logo animation ──
433
+ function renderLogoWave() {
434
+ try {
435
+ const coloredChars = LOGO_CHARS.map((ch, i) => {
436
+ const colorIdx = (i + waveOffset) % WAVE_COLORS.length;
437
+ return `{${WAVE_COLORS[colorIdx]}-fg}${ch}{/${WAVE_COLORS[colorIdx]}-fg}`;
438
+ });
439
+ const logoStr = ` ${coloredChars.join('')} Swarm `;
440
+ // Duration
441
+ const elapsedMs = Date.now() - launchTs;
442
+ const mins = Math.round(elapsedMs / 60000);
443
+ const durStr = mins >= 60 ? `${Math.floor(mins / 60)}h${mins % 60}m` : `${mins}m`;
444
+ const totalCost = lastWorkers.reduce((s, w) => s + w.totalCost, 0);
445
+ const totalTurns = lastWorkers.reduce((s, w) => s + w.turnCount, 0);
446
+ const rawLogoLen = 14; // " ekkOS_ Swarm " roughly
447
+ const infoStr = ` ${lastWorkers.length}W ${totalTurns}t $${totalCost.toFixed(2)} ${durStr} `;
448
+ const rawInfoLen = infoStr.length + 1;
449
+ const boxW = screen.width - 2;
450
+ const pad = Math.max(1, boxW - rawLogoLen - rawInfoLen);
451
+ headerBox.setLabel(logoStr + '\u2500'.repeat(pad) + infoStr);
452
+ // Header content: task description
453
+ const maxW = screen.width - 6;
454
+ const truncTask = taskStr.length > maxW ? taskStr.slice(0, maxW - 3) + '...' : taskStr;
455
+ headerBox.setContent(` {gray-fg}Task:{/gray-fg} ${truncTask}`);
456
+ waveOffset = (waveOffset + 1) % WAVE_COLORS.length;
457
+ screen.render();
458
+ }
459
+ catch { }
460
+ }
461
+ function scheduleWave() {
462
+ sparkleTimer = setTimeout(() => {
463
+ renderLogoWave();
464
+ scheduleWave();
465
+ }, 200);
466
+ }
467
+ scheduleWave();
468
+ // ── Worker status cards ──
469
+ function renderWorkerCards(workers) {
470
+ if (workers.length === 0) {
471
+ workersBox.setContent(' {gray-fg}No workers detected yet...{/gray-fg}');
472
+ return;
473
+ }
474
+ const boxW = Math.max(20, screen.width - 4);
475
+ const WORKER_COLORS = ['magenta', 'blue', 'green', 'yellow', 'cyan', 'red', 'white', 'gray'];
476
+ const lines = [];
477
+ // Row 1: Worker names + context bars
478
+ // Row 2: Stats (turns, cost, model)
479
+ // Row 3: Status indicators
480
+ const cardWidth = Math.floor(boxW / Math.max(workers.length, 1));
481
+ const barWidth = Math.max(4, cardWidth - 14); // space for "W1: " and " 62%"
482
+ let row1 = '';
483
+ let row2 = '';
484
+ let row3 = '';
485
+ for (let i = 0; i < workers.length; i++) {
486
+ const w = workers[i];
487
+ const color = WORKER_COLORS[i % WORKER_COLORS.length];
488
+ const ctxPct = Math.min(w.currentContextPct, 100);
489
+ const ctxColor = ctxPct < 50 ? 'green' : ctxPct < 80 ? 'yellow' : 'red';
490
+ // Context bar
491
+ const filled = Math.round((ctxPct / 100) * barWidth);
492
+ const bar = `{${ctxColor}-fg}${'#'.repeat(filled)}{/${ctxColor}-fg}${'.'.repeat(barWidth - filled)}`;
493
+ // Model tag
494
+ const mTag = modelTag(w.model);
495
+ const mColor = w.model.includes('haiku') ? 'green' : w.model.includes('sonnet') ? 'blue' : 'magenta';
496
+ // Status
497
+ const status = w.turnCount === 0
498
+ ? '{gray-fg}INIT{/gray-fg}'
499
+ : (w.turns.length > 0 && (Date.now() - new Date(w.lastActivity).getTime()) < 30000)
500
+ ? `{green-fg}ACTIVE{/green-fg}`
501
+ : '{yellow-fg}IDLE{/yellow-fg}';
502
+ const label = `{${color}-fg}W${w.workerIndex}{/${color}-fg}`;
503
+ const sep = i < workers.length - 1 ? ' {gray-fg}|{/gray-fg} ' : '';
504
+ row1 += ` ${label}: ${bar} ${ctxPct.toFixed(0)}%${sep}`;
505
+ row2 += ` {gray-fg}T${w.turnCount}{/gray-fg} {green-fg}$${w.totalCost.toFixed(2)}{/green-fg} {${mColor}-fg}${mTag}{/${mColor}-fg}${sep.replace('|', ' ')}`;
506
+ row3 += ` ${status} {gray-fg}${w.sessionName.slice(0, 11)}{/gray-fg}${sep}`;
507
+ }
508
+ workersBox.setContent(`${row1}\n${row2}\n${row3}`);
509
+ }
510
+ // ── Combined turn table ──
511
+ function renderTurnTable(workers) {
512
+ const WORKER_COLORS = ['magenta', 'blue', 'green', 'yellow', 'cyan', 'red', 'white', 'gray'];
513
+ const w = Math.max(20, screen.width - 4);
514
+ const div = '{gray-fg}|{/gray-fg}';
515
+ // Columns: W(2) Turn(4) Model(7) Context(7) CacheRd(flex) CacheWr(flex) Out(flex) Cost(7)
516
+ const colW = 2;
517
+ const colNum = 4;
518
+ const colM = 7;
519
+ const colCtx = 7;
520
+ const colCost = 7;
521
+ const fixedW = colW + colNum + colM + colCtx + colCost;
522
+ const nDividers = 7;
523
+ const flexTotal = Math.max(9, w - fixedW - nDividers);
524
+ const rdW = Math.floor(flexTotal * 0.35);
525
+ const wrW = Math.floor(flexTotal * 0.30);
526
+ const outW = flexTotal - rdW - wrW;
527
+ function pad(s, width) {
528
+ return s.length >= width ? s.slice(0, width) : s + ' '.repeat(width - s.length);
529
+ }
530
+ function rpad(s, width) {
531
+ return s.length >= width ? s.slice(0, width) : ' '.repeat(width - s.length) + s;
532
+ }
533
+ const header = `{bold}${pad('W', colW)}${div}${pad('Turn', colNum)}${div}${pad('Model', colM)}${div}${pad('Context', colCtx)}${div}${rpad('Cache Rd', rdW)}${div}${rpad('Cache Wr', wrW)}${div}${rpad('Output', outW)}${div}${rpad('Cost', colCost)}{/bold}`;
534
+ const separator = `{gray-fg}${'~'.repeat(colW)}+${'~'.repeat(colNum)}+${'~'.repeat(colM)}+${'~'.repeat(colCtx)}+${'~'.repeat(rdW)}+${'~'.repeat(wrW)}+${'~'.repeat(outW)}+${'~'.repeat(colCost)}{/gray-fg}`;
535
+ const allTurns = [];
536
+ for (const worker of workers) {
537
+ for (const turn of worker.turns) {
538
+ allTurns.push({ ...turn, workerIndex: worker.workerIndex });
539
+ }
540
+ }
541
+ // Sort by timestamp descending (newest first)
542
+ allTurns.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
543
+ const visibleRows = Math.max(1, layout.table.height - 4);
544
+ const rows = allTurns.slice(0, visibleRows).map(t => {
545
+ const wColor = WORKER_COLORS[(t.workerIndex - 1) % WORKER_COLORS.length];
546
+ const mTag = modelTag(t.routedModel);
547
+ const mColor = t.routedModel.includes('haiku') ? 'green' : t.routedModel.includes('sonnet') ? 'blue' : 'magenta';
548
+ const costFlag = t.cost > 1.0 ? '{red-fg}' : t.cost > 0.5 ? '{yellow-fg}' : '{white-fg}';
549
+ const costEnd = t.cost > 1.0 ? '{/red-fg}' : t.cost > 0.5 ? '{/yellow-fg}' : '{/white-fg}';
550
+ return (`{${wColor}-fg}${pad(String(t.workerIndex), colW)}{/${wColor}-fg}` + div +
551
+ pad(String(t.turn), colNum) + div +
552
+ `{${mColor}-fg}${pad(mTag, colM)}{/${mColor}-fg}` + div +
553
+ pad(`${t.contextPct.toFixed(0)}%`, colCtx) + div +
554
+ `{green-fg}${rpad(fmtK(t.cacheRead), rdW)}{/green-fg}` + div +
555
+ `{yellow-fg}${rpad(fmtK(t.cacheCreate), wrW)}{/yellow-fg}` + div +
556
+ `{cyan-fg}${rpad(fmtK(t.output), outW)}{/cyan-fg}` + div +
557
+ costFlag + rpad(`$${t.cost.toFixed(2)}`, colCost) + costEnd);
558
+ });
559
+ turnBox.setContent([header, separator, ...rows].join('\n'));
560
+ }
561
+ // ── Combined chart ──
562
+ function renderChart(workers) {
563
+ const WORKER_COLORS_RAW = ['magenta', 'blue', 'green', 'yellow', 'cyan', 'red'];
564
+ const series = [];
565
+ for (const w of workers) {
566
+ if (w.turns.length < 2)
567
+ continue;
568
+ const recent = w.turns.slice(-20);
569
+ const x = recent.map(t => String(t.turn));
570
+ const color = WORKER_COLORS_RAW[(w.workerIndex - 1) % WORKER_COLORS_RAW.length];
571
+ series.push({
572
+ title: `W${w.workerIndex}`,
573
+ x,
574
+ y: recent.map(t => Math.round((t.cacheRead + t.output) / 1000)),
575
+ style: { line: color },
576
+ });
577
+ }
578
+ if (series.length > 0) {
579
+ tokenChart.setData(series);
580
+ }
581
+ }
582
+ // ── Usage window (Anthropic OAuth) ──
583
+ async function fetchAnthropicUsage() {
584
+ try {
585
+ const { execSync } = require('child_process');
586
+ const credsJson = execSync('security find-generic-password -s "Claude Code-credentials" -w', { encoding: 'utf-8', timeout: 5000 }).trim();
587
+ const creds = JSON.parse(credsJson);
588
+ const token = creds?.claudeAiOauth?.accessToken;
589
+ if (!token)
590
+ return null;
591
+ const resp = await fetch('https://api.anthropic.com/api/oauth/usage', {
592
+ headers: {
593
+ 'Authorization': `Bearer ${token}`,
594
+ 'anthropic-beta': 'oauth-2025-04-20',
595
+ 'Content-Type': 'application/json',
596
+ 'User-Agent': 'ekkos-cli/swarm-dashboard',
597
+ },
598
+ });
599
+ if (!resp.ok)
600
+ return null;
601
+ return await resp.json();
602
+ }
603
+ catch {
604
+ return null;
605
+ }
606
+ }
607
+ async function updateWindowBox() {
608
+ try {
609
+ const usage = await fetchAnthropicUsage();
610
+ let line1 = ' {gray-fg}No usage data{/gray-fg}';
611
+ let line2 = '';
612
+ if (usage?.five_hour) {
613
+ const pct = usage.five_hour.utilization;
614
+ const resetAt = new Date(usage.five_hour.resets_at).getTime();
615
+ const remainMs = Math.max(0, resetAt - Date.now());
616
+ const remainMin = Math.round(remainMs / 60000);
617
+ const rH = Math.floor(remainMin / 60);
618
+ const rM = remainMin % 60;
619
+ const pctColor = pct < 50 ? 'green' : pct < 80 ? 'yellow' : 'red';
620
+ const timeColor = remainMin > 120 ? 'green' : remainMin > 60 ? 'yellow' : 'red';
621
+ line1 = ` {bold}5h:{/bold} {${pctColor}-fg}${pct.toFixed(0)}% used{/${pctColor}-fg} {${timeColor}-fg}${rH}h${rM}m left{/${timeColor}-fg}`;
622
+ }
623
+ if (usage?.seven_day) {
624
+ const pct = usage.seven_day.utilization;
625
+ const resetAt = new Date(usage.seven_day.resets_at);
626
+ const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
627
+ const resetDay = dayNames[resetAt.getDay()];
628
+ const resetHour = resetAt.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
629
+ const pctColor = pct < 50 ? 'green' : pct < 80 ? 'yellow' : 'red';
630
+ line2 = ` {bold}Week:{/bold} {${pctColor}-fg}${pct.toFixed(0)}% used{/${pctColor}-fg} resets ${resetDay} ${resetHour}`;
631
+ }
632
+ windowBox.setContent(line1 + (line2 ? '\n' + line2 : ''));
633
+ }
634
+ catch {
635
+ windowBox.setContent(` {gray-fg}Usage data unavailable{/gray-fg}`);
636
+ }
637
+ try {
638
+ screen.render();
639
+ }
640
+ catch { }
641
+ }
642
+ // ── Footer ──
643
+ function renderFooter(workers) {
644
+ const totalCost = workers.reduce((s, w) => s + w.totalCost, 0);
645
+ const totalTurns = workers.reduce((s, w) => s + w.turnCount, 0);
646
+ const totalSavings = workers.reduce((s, w) => s + w.turns.reduce((ts, t) => ts + t.savings, 0), 0);
647
+ const allTurns = workers.flatMap(w => w.turns);
648
+ const totalTokensM = ((workers.reduce((s, w) => s + w.totalTokens, 0)) / 1000000).toFixed(2);
649
+ const opusCount = allTurns.filter(t => t.routedModel.includes('opus')).length;
650
+ const sonnetCount = allTurns.filter(t => t.routedModel.includes('sonnet')).length;
651
+ const haikuCount = allTurns.filter(t => t.routedModel.includes('haiku')).length;
652
+ const routingParts = [`{magenta-fg}O{/magenta-fg}:${opusCount}`];
653
+ if (sonnetCount > 0)
654
+ routingParts.push(`{blue-fg}S{/blue-fg}:${sonnetCount}`);
655
+ routingParts.push(`{green-fg}H{/green-fg}:${haikuCount}`);
656
+ const savingsStr = totalSavings > 0 ? ` {green-fg}saved $${totalSavings.toFixed(2)}{/green-fg}` : '';
657
+ footerBox.setContent(` {green-fg}$${totalCost.toFixed(2)}{/green-fg}` +
658
+ ` ${totalTurns}t` +
659
+ ` ${totalTokensM}M` +
660
+ ` ${routingParts.join(' ')}` +
661
+ savingsStr +
662
+ ` {gray-fg}q r 0-${workers.length}{/gray-fg}`);
663
+ }
664
+ // ── Main update loop ──
665
+ function updateDashboard() {
666
+ try {
667
+ const workers = discoverWorkers(launchTs);
668
+ lastWorkers = workers;
669
+ renderWorkerCards(workers);
670
+ renderChart(workers);
671
+ renderTurnTable(workers);
672
+ renderFooter(workers);
673
+ screen.render();
674
+ }
675
+ catch { }
676
+ }
677
+ // ── Keyboard ──
678
+ screen.key(['q', 'C-c'], () => {
679
+ clearInterval(pollInterval);
680
+ clearInterval(windowPollInterval);
681
+ clearTimeout(sparkleTimer);
682
+ screen.destroy();
683
+ process.exit(0);
684
+ });
685
+ screen.key(['r'], () => updateDashboard());
686
+ // Scroll controls
687
+ screen.key(['up', 'k'], () => { turnBox.scroll(-1); screen.render(); });
688
+ screen.key(['down', 'j'], () => { turnBox.scroll(1); screen.render(); });
689
+ screen.key(['pageup', 'u'], () => { turnBox.scroll(-(turnBox.height - 2)); screen.render(); });
690
+ screen.key(['pagedown', 'd'], () => { turnBox.scroll((turnBox.height - 2)); screen.render(); });
691
+ screen.on('resize', () => {
692
+ try {
693
+ screen.realloc?.();
694
+ applyLayout();
695
+ updateDashboard();
696
+ }
697
+ catch { }
698
+ });
699
+ // Don't auto-focus in tmux
700
+ const inTmux = process.env.TMUX !== undefined;
701
+ if (!inTmux)
702
+ turnBox.focus();
703
+ screen.program.clear();
704
+ updateDashboard();
705
+ screen.render();
706
+ setTimeout(() => updateWindowBox(), 2000);
707
+ const pollInterval = setInterval(updateDashboard, refreshMs);
708
+ const windowPollInterval = setInterval(updateWindowBox, 15000);
709
+ }
710
+ // ── Commander command ──
711
+ exports.swarmDashboardCommand = new commander_1.Command('dashboard')
712
+ .description('Live TUI dashboard for monitoring swarm workers')
713
+ .option('--launch-ts <ts>', 'Swarm launch timestamp (auto-set by swarm launch)')
714
+ .option('--refresh <ms>', 'Polling interval in ms', '2000')
715
+ .action(async (options) => {
716
+ const refreshMs = parseInt(options.refresh) || 2000;
717
+ let launchTs;
718
+ if (options.launchTs) {
719
+ launchTs = parseInt(options.launchTs);
720
+ }
721
+ else {
722
+ // Try to read from active-swarm.json
723
+ const meta = readSwarmMeta();
724
+ if (meta?.launchTs) {
725
+ launchTs = meta.launchTs;
726
+ console.log(chalk_1.default.gray(` Auto-detected swarm from ${new Date(launchTs).toLocaleTimeString()}`));
727
+ }
728
+ else {
729
+ console.log(chalk_1.default.red('No active swarm found.'));
730
+ console.log(chalk_1.default.gray(' Run "ekkos swarm launch" first.'));
731
+ process.exit(1);
732
+ }
733
+ }
734
+ await launchSwarmDashboard(launchTs, refreshMs);
735
+ });