@ekkos/cli 0.3.3 → 1.0.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 (81) 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/transcript-repair.d.ts +1 -0
  9. package/dist/capture/transcript-repair.js +12 -1
  10. package/dist/commands/agent.d.ts +6 -0
  11. package/dist/commands/agent.js +244 -0
  12. package/dist/commands/dashboard.d.ts +25 -0
  13. package/dist/commands/dashboard.js +1175 -0
  14. package/dist/commands/run.d.ts +3 -0
  15. package/dist/commands/run.js +503 -350
  16. package/dist/commands/setup-remote.js +146 -37
  17. package/dist/commands/swarm-dashboard.d.ts +20 -0
  18. package/dist/commands/swarm-dashboard.js +735 -0
  19. package/dist/commands/swarm-setup.d.ts +10 -0
  20. package/dist/commands/swarm-setup.js +956 -0
  21. package/dist/commands/swarm.d.ts +46 -0
  22. package/dist/commands/swarm.js +441 -0
  23. package/dist/commands/test-claude.d.ts +16 -0
  24. package/dist/commands/test-claude.js +156 -0
  25. package/dist/commands/usage/blocks.d.ts +8 -0
  26. package/dist/commands/usage/blocks.js +60 -0
  27. package/dist/commands/usage/daily.d.ts +9 -0
  28. package/dist/commands/usage/daily.js +96 -0
  29. package/dist/commands/usage/dashboard.d.ts +8 -0
  30. package/dist/commands/usage/dashboard.js +104 -0
  31. package/dist/commands/usage/formatters.d.ts +41 -0
  32. package/dist/commands/usage/formatters.js +147 -0
  33. package/dist/commands/usage/index.d.ts +13 -0
  34. package/dist/commands/usage/index.js +87 -0
  35. package/dist/commands/usage/monthly.d.ts +8 -0
  36. package/dist/commands/usage/monthly.js +66 -0
  37. package/dist/commands/usage/session.d.ts +11 -0
  38. package/dist/commands/usage/session.js +193 -0
  39. package/dist/commands/usage/weekly.d.ts +9 -0
  40. package/dist/commands/usage/weekly.js +61 -0
  41. package/dist/deploy/instructions.d.ts +5 -2
  42. package/dist/deploy/instructions.js +11 -8
  43. package/dist/index.js +256 -20
  44. package/dist/lib/tmux-scrollbar.d.ts +14 -0
  45. package/dist/lib/tmux-scrollbar.js +296 -0
  46. package/dist/lib/usage-parser.d.ts +95 -5
  47. package/dist/lib/usage-parser.js +416 -71
  48. package/dist/utils/log-rotate.d.ts +18 -0
  49. package/dist/utils/log-rotate.js +74 -0
  50. package/dist/utils/platform.d.ts +2 -0
  51. package/dist/utils/platform.js +3 -1
  52. package/dist/utils/session-binding.d.ts +5 -0
  53. package/dist/utils/session-binding.js +46 -0
  54. package/dist/utils/state.js +4 -0
  55. package/dist/utils/verify-remote-terminal.d.ts +10 -0
  56. package/dist/utils/verify-remote-terminal.js +415 -0
  57. package/package.json +16 -11
  58. package/templates/CLAUDE.md +135 -23
  59. package/templates/cursor-hooks/after-agent-response.sh +0 -0
  60. package/templates/cursor-hooks/before-submit-prompt.sh +0 -0
  61. package/templates/cursor-hooks/stop.sh +0 -0
  62. package/templates/ekkos-manifest.json +5 -5
  63. package/templates/hooks/assistant-response.sh +0 -0
  64. package/templates/hooks/lib/contract.sh +43 -31
  65. package/templates/hooks/lib/count-tokens.cjs +86 -0
  66. package/templates/hooks/lib/ekkos-reminders.sh +98 -0
  67. package/templates/hooks/lib/state.sh +53 -1
  68. package/templates/hooks/session-start.sh +0 -0
  69. package/templates/hooks/stop.sh +150 -388
  70. package/templates/hooks/user-prompt-submit.sh +353 -443
  71. package/templates/plan-template.md +0 -0
  72. package/templates/spec-template.md +0 -0
  73. package/templates/windsurf-hooks/README.md +212 -0
  74. package/templates/windsurf-hooks/hooks.json +9 -2
  75. package/templates/windsurf-hooks/install.sh +148 -0
  76. package/templates/windsurf-hooks/lib/contract.sh +2 -0
  77. package/templates/windsurf-hooks/post-cascade-response.sh +251 -0
  78. package/templates/windsurf-hooks/pre-user-prompt.sh +435 -0
  79. package/templates/windsurf-skills/ekkos-memory/SKILL.md +219 -0
  80. package/LICENSE +0 -21
  81. package/templates/windsurf-hooks/before-submit-prompt.sh +0 -238
@@ -0,0 +1,1175 @@
1
+ "use strict";
2
+ /**
3
+ * ekkos dashboard [session-name]
4
+ *
5
+ * Live TUI dashboard for monitoring Claude Code session usage in real-time.
6
+ * Uses blessed-contrib for rich terminal widgets (gauges, charts, tables).
7
+ *
8
+ * Usage:
9
+ * ekkos dashboard <session-name> Watch specific session
10
+ * ekkos dashboard --latest Auto-detect latest active session
11
+ * ekkos dashboard --wait-for-new Wait for a brand-new session to appear
12
+ * ekkos dashboard Interactive session picker
13
+ *
14
+ * Text Selection:
15
+ * To select text separately from Claude Code, run the dashboard in a different
16
+ * terminal window/pane. This prevents the blessed screen from interfering with
17
+ * Claude Code's text selection. Use iTerm2 split panes or tmux windows.
18
+ *
19
+ * Scrolling:
20
+ * Up/Down arrows or j/k Scroll one line
21
+ * PageUp/PageDown or u/d Scroll one page
22
+ * Home/End or g/G Jump to top/bottom
23
+ * Mouse wheel Scroll with mouse
24
+ */
25
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
26
+ if (k2 === undefined) k2 = k;
27
+ var desc = Object.getOwnPropertyDescriptor(m, k);
28
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
29
+ desc = { enumerable: true, get: function() { return m[k]; } };
30
+ }
31
+ Object.defineProperty(o, k2, desc);
32
+ }) : (function(o, m, k, k2) {
33
+ if (k2 === undefined) k2 = k;
34
+ o[k2] = m[k];
35
+ }));
36
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
37
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
38
+ }) : function(o, v) {
39
+ o["default"] = v;
40
+ });
41
+ var __importStar = (this && this.__importStar) || (function () {
42
+ var ownKeys = function(o) {
43
+ ownKeys = Object.getOwnPropertyNames || function (o) {
44
+ var ar = [];
45
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
46
+ return ar;
47
+ };
48
+ return ownKeys(o);
49
+ };
50
+ return function (mod) {
51
+ if (mod && mod.__esModule) return mod;
52
+ var result = {};
53
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
54
+ __setModuleDefault(result, mod);
55
+ return result;
56
+ };
57
+ })();
58
+ var __importDefault = (this && this.__importDefault) || function (mod) {
59
+ return (mod && mod.__esModule) ? mod : { "default": mod };
60
+ };
61
+ Object.defineProperty(exports, "__esModule", { value: true });
62
+ exports.dashboardCommand = void 0;
63
+ const fs = __importStar(require("fs"));
64
+ const os = __importStar(require("os"));
65
+ const path = __importStar(require("path"));
66
+ const chalk_1 = __importDefault(require("chalk"));
67
+ const commander_1 = require("commander");
68
+ const usage_parser_js_1 = require("../lib/usage-parser.js");
69
+ const state_js_1 = require("../utils/state.js");
70
+ // ── Pricing ──
71
+ // Pricing per MTok from https://platform.claude.com/docs/en/about-claude/pricing
72
+ const MODEL_PRICING = {
73
+ 'claude-opus-4-6': { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.50 },
74
+ 'claude-opus-4-5-20250620': { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.50 },
75
+ 'claude-sonnet-4-5-20250929': { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.30 },
76
+ 'claude-sonnet-4-5-20250514': { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.30 },
77
+ 'claude-haiku-4-5-20251001': { input: 0.80, output: 4, cacheWrite: 1.00, cacheRead: 0.08 },
78
+ };
79
+ function getModelPricing(modelId) {
80
+ if (MODEL_PRICING[modelId])
81
+ return MODEL_PRICING[modelId];
82
+ if (modelId.includes('opus'))
83
+ return MODEL_PRICING['claude-opus-4-6'];
84
+ if (modelId.includes('sonnet'))
85
+ return MODEL_PRICING['claude-sonnet-4-5-20250929'];
86
+ if (modelId.includes('haiku'))
87
+ return MODEL_PRICING['claude-haiku-4-5-20251001'];
88
+ return MODEL_PRICING['claude-sonnet-4-5-20250929'];
89
+ }
90
+ function calculateTurnCost(model, usage) {
91
+ const p = getModelPricing(model);
92
+ return ((usage.input_tokens / 1000000) * p.input +
93
+ (usage.output_tokens / 1000000) * p.output +
94
+ (usage.cache_creation_tokens / 1000000) * p.cacheWrite +
95
+ (usage.cache_read_tokens / 1000000) * p.cacheRead);
96
+ }
97
+ function getModelCtxSize(model) {
98
+ if (model.includes('opus'))
99
+ return 200000;
100
+ if (model.includes('haiku'))
101
+ return 200000;
102
+ if (model.includes('sonnet'))
103
+ return 200000;
104
+ return 200000; // Default Anthropic context
105
+ }
106
+ /** Model tag for dashboard display */
107
+ function modelTag(model) {
108
+ if (model.includes('opus'))
109
+ return 'Opus';
110
+ if (model.includes('sonnet'))
111
+ return 'Sonnet';
112
+ if (model.includes('haiku'))
113
+ return 'Haiku';
114
+ return '?';
115
+ }
116
+ function parseCacheHintValue(cacheHint, key) {
117
+ if (!cacheHint || typeof cacheHint !== 'string')
118
+ return null;
119
+ const parts = cacheHint.split(';');
120
+ for (const part of parts) {
121
+ const [k, ...rest] = part.split('=');
122
+ if (k?.trim() === key) {
123
+ const value = rest.join('=').trim();
124
+ return value || null;
125
+ }
126
+ }
127
+ return null;
128
+ }
129
+ function parseJsonlFile(jsonlPath, sessionName) {
130
+ const content = fs.readFileSync(jsonlPath, 'utf-8');
131
+ const lines = content.trim().split('\n');
132
+ const turnsByMsgId = new Map();
133
+ const msgIdOrder = [];
134
+ let startedAt = '';
135
+ let model = 'unknown';
136
+ const toolsByMessage = new Map();
137
+ for (const line of lines) {
138
+ try {
139
+ const entry = JSON.parse(line);
140
+ if (entry.type === 'assistant' && entry.message?.content) {
141
+ const msgId = entry.message.id;
142
+ for (const block of entry.message.content) {
143
+ if (block.type === 'tool_use' && block.name) {
144
+ if (!toolsByMessage.has(msgId))
145
+ toolsByMessage.set(msgId, new Set());
146
+ toolsByMessage.get(msgId).add(block.name);
147
+ }
148
+ }
149
+ }
150
+ if (entry.type === 'assistant' && entry.message?.usage) {
151
+ const msgId = entry.message.id;
152
+ const isNew = msgId && !turnsByMsgId.has(msgId);
153
+ const usage = entry.message.usage;
154
+ model = entry.message.model || model;
155
+ // Smart routing: _ekkos_routed_model contains the actual model used
156
+ const routedModel = entry.message._ekkos_routed_model || entry.message.model || model;
157
+ const cacheHint = typeof entry.message._ekkos_cache_hint === 'string'
158
+ ? entry.message._ekkos_cache_hint
159
+ : undefined;
160
+ const replayState = typeof entry.message._ekkos_replay_state === 'string'
161
+ ? entry.message._ekkos_replay_state
162
+ : (parseCacheHintValue(cacheHint, 'replay') || 'unknown');
163
+ const replayStore = typeof entry.message._ekkos_replay_store === 'string'
164
+ ? entry.message._ekkos_replay_store
165
+ : 'none';
166
+ const evictionState = typeof entry.message._ekkos_eviction_state === 'string'
167
+ ? entry.message._ekkos_eviction_state
168
+ : (parseCacheHintValue(cacheHint, 'eviction') || 'unknown');
169
+ const inputTokens = usage.input_tokens || 0;
170
+ const outputTokens = usage.output_tokens || 0;
171
+ const cacheReadTokens = usage.cache_read_input_tokens || 0;
172
+ const cacheCreationTokens = usage.cache_creation_input_tokens || 0;
173
+ const contextTokens = inputTokens + cacheReadTokens + cacheCreationTokens;
174
+ const modelCtxSize = getModelCtxSize(model);
175
+ const contextPct = (contextTokens / modelCtxSize) * 100;
176
+ const ts = entry.timestamp || new Date().toISOString();
177
+ if (!startedAt)
178
+ startedAt = ts;
179
+ const usageData = {
180
+ input_tokens: inputTokens,
181
+ output_tokens: outputTokens,
182
+ cache_read_tokens: cacheReadTokens,
183
+ cache_creation_tokens: cacheCreationTokens,
184
+ };
185
+ // Cost at actual model pricing (Haiku if routed)
186
+ const turnCost = calculateTurnCost(routedModel, usageData);
187
+ // Cost if it had been Opus (for savings calculation)
188
+ const opusCost = routedModel !== model
189
+ ? calculateTurnCost(model, usageData)
190
+ : turnCost;
191
+ const savings = opusCost - turnCost;
192
+ const msgTools = toolsByMessage.get(msgId);
193
+ const toolStr = msgTools && msgTools.size > 0
194
+ ? Array.from(msgTools).map(t => t.replace(/^mcp__ekkos-memory__/, '').replace(/^ekkOS_/, '')).join(',')
195
+ : '-';
196
+ // Last entry wins — final JSONL entry for a message has the real output_tokens
197
+ const turnNum = isNew ? msgIdOrder.length + 1 : (turnsByMsgId.get(msgId).turn);
198
+ const turnData = {
199
+ turn: turnNum,
200
+ contextPct,
201
+ cacheRead: cacheReadTokens,
202
+ cacheCreate: cacheCreationTokens,
203
+ output: outputTokens,
204
+ cost: turnCost,
205
+ opusCost,
206
+ savings,
207
+ tools: toolStr,
208
+ model,
209
+ routedModel,
210
+ replayState,
211
+ replayStore,
212
+ evictionState,
213
+ timestamp: ts,
214
+ };
215
+ if (msgId) {
216
+ if (isNew)
217
+ msgIdOrder.push(msgId);
218
+ turnsByMsgId.set(msgId, turnData);
219
+ }
220
+ }
221
+ }
222
+ catch { /* skip bad lines */ }
223
+ }
224
+ // Build ordered turns array from the Map (last-entry-wins dedup)
225
+ const turns = msgIdOrder.map(id => turnsByMsgId.get(id));
226
+ const totalCost = turns.reduce((s, t) => s + t.cost, 0);
227
+ const totalCacheRead = turns.reduce((s, t) => s + t.cacheRead, 0);
228
+ const totalCacheCreate = turns.reduce((s, t) => s + t.cacheCreate, 0);
229
+ const totalOutput = turns.reduce((s, t) => s + t.output, 0);
230
+ const maxContextPct = turns.length > 0 ? Math.max(...turns.map(t => t.contextPct)) : 0;
231
+ const currentContextPct = turns.length > 0 ? turns[turns.length - 1].contextPct : 0;
232
+ const avgCostPerTurn = turns.length > 0 ? totalCost / turns.length : 0;
233
+ const cacheHitRate = (totalCacheRead + totalCacheCreate) > 0
234
+ ? (totalCacheRead / (totalCacheRead + totalCacheCreate)) * 100
235
+ : 0;
236
+ const replayAppliedCount = turns.reduce((sum, t) => sum + (t.replayState === 'applied' ? 1 : 0), 0);
237
+ const replaySkippedSizeCount = turns.reduce((sum, t) => sum + (t.replayState === 'skipped-size' ? 1 : 0), 0);
238
+ const replaySkipStoreCount = turns.reduce((sum, t) => sum + (t.replayStore === 'skip-size' ? 1 : 0), 0);
239
+ let duration = '0m';
240
+ if (startedAt && turns.length > 0) {
241
+ const start = new Date(startedAt).getTime();
242
+ const end = new Date(turns[turns.length - 1].timestamp).getTime();
243
+ const mins = Math.round((end - start) / 60000);
244
+ duration = mins >= 60 ? `${Math.floor(mins / 60)}h${mins % 60}m` : `${mins}m`;
245
+ }
246
+ // Get current context tokens from the last turn's raw data
247
+ const lastTurn = turns.length > 0 ? turns[turns.length - 1] : null;
248
+ const currentContextTokens = lastTurn
249
+ ? lastTurn.cacheRead + lastTurn.cacheCreate + (lastTurn.output || 0)
250
+ : 0;
251
+ const modelContextSize = getModelCtxSize(model);
252
+ return {
253
+ sessionName,
254
+ model,
255
+ turnCount: turns.length,
256
+ totalCost,
257
+ totalTokens: totalCacheRead + totalCacheCreate + totalOutput,
258
+ totalCacheRead,
259
+ totalCacheCreate,
260
+ totalOutput,
261
+ avgCostPerTurn,
262
+ maxContextPct,
263
+ currentContextPct,
264
+ currentContextTokens,
265
+ modelContextSize,
266
+ cacheHitRate,
267
+ replayAppliedCount,
268
+ replaySkippedSizeCount,
269
+ replaySkipStoreCount,
270
+ startedAt,
271
+ duration,
272
+ turns,
273
+ };
274
+ }
275
+ // ── Resolve session to JSONL path ──
276
+ function resolveJsonlPath(sessionName, createdAfterMs) {
277
+ // 1) Try standard resolution (works when sessionId is a real UUID)
278
+ const resolved = (0, usage_parser_js_1.resolveSessionName)(sessionName);
279
+ if (resolved) {
280
+ const jsonlPath = path.join(os.homedir(), '.claude', 'projects', resolved.encodedProjectPath, `${resolved.uuid}.jsonl`);
281
+ if (fs.existsSync(jsonlPath))
282
+ return jsonlPath;
283
+ }
284
+ // 2) Fallback: active session has "pending" UUID
285
+ const activeSessionsPath = path.join(os.homedir(), '.ekkos', 'active-sessions.json');
286
+ if (fs.existsSync(activeSessionsPath)) {
287
+ try {
288
+ const sessions = JSON.parse(fs.readFileSync(activeSessionsPath, 'utf-8'));
289
+ const match = sessions.find((s) => s.sessionName === sessionName);
290
+ if (match?.projectPath) {
291
+ return findLatestJsonl(match.projectPath, createdAfterMs);
292
+ }
293
+ }
294
+ catch { /* ignore */ }
295
+ }
296
+ return null;
297
+ }
298
+ /**
299
+ * Find a JSONL file in a project directory.
300
+ * @param createdAfterMs - If set, only return files CREATED (birthtime) after this timestamp.
301
+ * This prevents picking up old sessions that are still being modified.
302
+ */
303
+ function findLatestJsonl(projectPath, createdAfterMs) {
304
+ const encoded = projectPath.replace(/\//g, '-');
305
+ const projectDir = path.join(os.homedir(), '.claude', 'projects', encoded);
306
+ if (!fs.existsSync(projectDir))
307
+ return null;
308
+ const jsonlFiles = fs.readdirSync(projectDir)
309
+ .filter(f => f.endsWith('.jsonl'))
310
+ .map(f => {
311
+ const stat = fs.statSync(path.join(projectDir, f));
312
+ return {
313
+ path: path.join(projectDir, f),
314
+ mtime: stat.mtimeMs,
315
+ birthtime: stat.birthtimeMs,
316
+ };
317
+ })
318
+ .filter(f => !createdAfterMs || f.birthtime > createdAfterMs)
319
+ .sort((a, b) => b.mtime - a.mtime);
320
+ return jsonlFiles.length > 0 ? jsonlFiles[0].path : null;
321
+ }
322
+ function getLatestSession() {
323
+ const sessions = (0, state_js_1.getActiveSessions)();
324
+ if (sessions.length === 0)
325
+ return null;
326
+ sessions.sort((a, b) => (b.lastHeartbeat || '').localeCompare(a.lastHeartbeat || ''));
327
+ return sessions[0].sessionName;
328
+ }
329
+ /**
330
+ * Wait for a new session to appear (used with --wait-for-new from ekkos run --dashboard).
331
+ * Polls active-sessions.json until a session started AFTER the launch marker timestamp.
332
+ * Returns the JSONL path for the new session.
333
+ */
334
+ async function waitForNewSession() {
335
+ // Read launch timestamp + CWD marker
336
+ const markerPath = path.join(state_js_1.EKKOS_DIR, '.dashboard-launch-ts');
337
+ let launchTs = Date.now() - 5000; // default: 5s ago
338
+ let launchCwd = null;
339
+ try {
340
+ const lines = fs.readFileSync(markerPath, 'utf-8').trim().split('\n');
341
+ launchTs = parseInt(lines[0]) || launchTs;
342
+ if (lines[1])
343
+ launchCwd = lines[1];
344
+ }
345
+ catch { }
346
+ console.log(chalk_1.default.gray(' Waiting for new session to start...'));
347
+ const maxWaitMs = 120000; // 2 minutes max
348
+ const pollMs = 3000;
349
+ const startWait = Date.now();
350
+ let candidateName = null;
351
+ while (Date.now() - startWait < maxWaitMs) {
352
+ const sessions = (0, state_js_1.getActiveSessions)();
353
+ // Find sessions that started after our launch
354
+ for (const s of sessions) {
355
+ const startedMs = new Date(s.startedAt).getTime();
356
+ if (startedMs >= launchTs - 2000) {
357
+ candidateName = s.sessionName;
358
+ // Try standard resolution (works when session bind has happened with real UUID)
359
+ const jsonlPath = resolveJsonlPath(s.sessionName, launchTs);
360
+ if (jsonlPath) {
361
+ console.log(chalk_1.default.green(` Found session: ${s.sessionName}`));
362
+ return { sessionName: s.sessionName, jsonlPath };
363
+ }
364
+ // Try all unique projectPaths for this session name (bind may create second entry)
365
+ const allPaths = new Set(sessions.filter(x => x.sessionName === s.sessionName && x.projectPath)
366
+ .map(x => x.projectPath));
367
+ for (const pp of allPaths) {
368
+ const latestJsonl = findLatestJsonl(pp, launchTs);
369
+ if (latestJsonl) {
370
+ console.log(chalk_1.default.green(` Found session: ${s.sessionName}`));
371
+ return { sessionName: s.sessionName, jsonlPath: latestJsonl };
372
+ }
373
+ }
374
+ }
375
+ }
376
+ // Fallback: use launch CWD to find any new JSONL
377
+ if (launchCwd) {
378
+ const latestJsonl = findLatestJsonl(launchCwd, launchTs);
379
+ if (latestJsonl) {
380
+ const name = candidateName || 'session';
381
+ console.log(chalk_1.default.green(` Found session via CWD: ${name}`));
382
+ return { sessionName: name, jsonlPath: latestJsonl };
383
+ }
384
+ }
385
+ await sleep(pollMs);
386
+ process.stdout.write(chalk_1.default.gray('.'));
387
+ }
388
+ // Timeout — try one more time with the candidate name (still use birthtime filter)
389
+ if (candidateName) {
390
+ console.log(chalk_1.default.yellow(`\n Timeout. Trying ${candidateName} anyway...`));
391
+ const jsonlPath = resolveJsonlPath(candidateName, launchTs);
392
+ if (jsonlPath)
393
+ return { sessionName: candidateName, jsonlPath };
394
+ }
395
+ // Last resort: latest session overall
396
+ const latestName = getLatestSession();
397
+ if (latestName) {
398
+ console.log(chalk_1.default.yellow(`\n Falling back to latest: ${latestName}`));
399
+ const jsonlPath = resolveJsonlPath(latestName);
400
+ if (jsonlPath)
401
+ return { sessionName: latestName, jsonlPath };
402
+ }
403
+ console.log(chalk_1.default.red(' No session found.'));
404
+ process.exit(1);
405
+ }
406
+ function sleep(ms) {
407
+ return new Promise(resolve => setTimeout(resolve, ms));
408
+ }
409
+ // ── TUI Dashboard ──
410
+ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
411
+ let sessionName = initialSessionName;
412
+ const blessed = require('blessed');
413
+ const contrib = require('blessed-contrib');
414
+ // ══════════════════════════════════════════════════════════════════════════
415
+ // TMUX SPLIT PANE ISOLATION
416
+ // When dashboard runs in a separate tmux pane from `ekkos run`, blessed must
417
+ // NOT consume stdin/stdout control, which would break both panes.
418
+ //
419
+ // KEY FIX: We use /dev/tty to give blessed DIRECT terminal access instead of
420
+ // inheriting stdin/stdout. This ensures:
421
+ // 1. Dashboard draws only to its own tmux pane (not to stdout pipe)
422
+ // 2. Terminal control (raw mode, alternate buffer) is local to that pane
423
+ // 3. Claude Code pane remains fully independent
424
+ // 4. Text selection works across panes without interference
425
+ // ══════════════════════════════════════════════════════════════════════════
426
+ // Open /dev/tty for blessed to use (gives it direct terminal access)
427
+ const fs = require('fs');
428
+ let ttyInput = process.stdin;
429
+ let ttyOutput = process.stdout;
430
+ try {
431
+ // Create readable/writable streams from /dev/tty file descriptors
432
+ const ttyFd = fs.openSync('/dev/tty', 'r+');
433
+ ttyInput = new (require('tty').ReadStream)(ttyFd);
434
+ ttyOutput = new (require('tty').WriteStream)(ttyFd);
435
+ }
436
+ catch (e) {
437
+ // Fallback: if /dev/tty unavailable (some CI/remote environments),
438
+ // use stdin/stdout but disable all event handling
439
+ ttyInput = process.stdin;
440
+ ttyOutput = process.stdout;
441
+ }
442
+ const screen = blessed.screen({
443
+ smartCSR: true,
444
+ title: `ekkOS - ${sessionName}`,
445
+ fullUnicode: true,
446
+ mouse: false, // Disable ALL mouse capture (allows terminal text selection)
447
+ grabKeys: false, // Don't grab keyboard input from other panes
448
+ sendFocus: false, // Don't send focus events (breaks paste)
449
+ ignoreLocked: ['C-c'], // Only capture Ctrl+C for quit
450
+ input: ttyInput, // Use /dev/tty for input (isolated from stdout pipe)
451
+ output: ttyOutput, // Use /dev/tty for output (isolated from stdout pipe)
452
+ forceUnicode: true, // Better text rendering
453
+ terminal: 'xterm-256color',
454
+ resizeTimeout: 300, // Debounce resize events
455
+ });
456
+ // ══════════════════════════════════════════════════════════════════════════
457
+ // DISABLE TERMINAL CONTROL SEQUENCE HIJACKING
458
+ // Prevent blessed from sending control sequences that might interfere with
459
+ // other panes in tmux. The /dev/tty approach already isolates this, but we
460
+ // also disable the raw mode entirely to be extra safe.
461
+ // ══════════════════════════════════════════════════════════════════════════
462
+ if (screen.program) {
463
+ // Override alternateBuffer to do nothing
464
+ screen.program.alternateBuffer = () => { };
465
+ screen.program.normalBuffer = () => { };
466
+ // Don't enter raw mode — let the OS handle it
467
+ if (screen.program.setRawMode) {
468
+ screen.program.setRawMode = (enabled) => {
469
+ // Silently ignore raw mode requests
470
+ return enabled;
471
+ };
472
+ }
473
+ }
474
+ // ── Zero-gap calculated layout ──
475
+ //
476
+ // Every widget has exact top + height computed from screen.height.
477
+ // Fixed heights for header/context/usage/footer, remaining split
478
+ // between chart (40%) and table (60%). No percentages = no gaps.
479
+ //
480
+ // header: 3 rows (session stats + animated logo)
481
+ // context: 5 rows (progress bar + cost breakdown + cache stats)
482
+ // chart: 40% of remaining (token usage graph)
483
+ // table: 60% of remaining (turn-by-turn breakdown)
484
+ // usage: 3 rows (Anthropic rate limit window)
485
+ // footer: 3 rows (totals + routing + keybindings)
486
+ const LOGO_CHARS = ['e', 'k', 'k', 'O', 'S', '_'];
487
+ const WAVE_COLORS = ['cyan', 'blue', 'magenta', 'yellow', 'green', 'white'];
488
+ const W = '100%';
489
+ const HEADER_H = 3;
490
+ const CONTEXT_H = 5;
491
+ const USAGE_H = 4;
492
+ const FOOTER_H = 3;
493
+ const CLAWD_W = 16; // Width reserved for Clawd mascot in context box
494
+ const FIXED_H = HEADER_H + CONTEXT_H + USAGE_H + FOOTER_H; // 15
495
+ function calcLayout() {
496
+ const H = screen.height;
497
+ const remaining = Math.max(6, H - FIXED_H);
498
+ const chartH = Math.max(4, Math.floor(remaining * 0.30));
499
+ const tableH = Math.max(4, remaining - chartH);
500
+ return {
501
+ header: { top: 0, height: HEADER_H },
502
+ context: { top: HEADER_H, height: CONTEXT_H },
503
+ chart: { top: HEADER_H + CONTEXT_H, height: chartH },
504
+ table: { top: HEADER_H + CONTEXT_H + chartH, height: tableH },
505
+ usage: { top: H - USAGE_H - FOOTER_H, height: USAGE_H },
506
+ footer: { top: H - FOOTER_H, height: FOOTER_H },
507
+ };
508
+ }
509
+ let layout = calcLayout();
510
+ // Header: session stats (3 lines)
511
+ const headerBox = blessed.box({
512
+ top: layout.header.top, left: 0, width: W, height: layout.header.height,
513
+ content: ' {gray-fg}Loading...{/gray-fg}',
514
+ tags: true,
515
+ style: { fg: 'white', border: { fg: 'cyan' } },
516
+ border: { type: 'line' },
517
+ label: ' ekkOS_ ',
518
+ });
519
+ // Context: progress bar + costs + cache (5 lines)
520
+ const contextBox = blessed.box({
521
+ top: layout.context.top, left: 0, width: W, height: layout.context.height,
522
+ content: '',
523
+ tags: true,
524
+ style: { fg: 'white', border: { fg: 'cyan' } },
525
+ border: { type: 'line' },
526
+ label: ' Context ',
527
+ });
528
+ // Clawd mascot — official Claude Code mascot (redBright / rgb(215,119,87))
529
+ const clawdBox = blessed.box({
530
+ parent: contextBox,
531
+ top: 0,
532
+ right: 2, // Keep a small visual gap from the context border
533
+ width: 10,
534
+ height: 3,
535
+ content: ' ▐▛███▜▌\n ▝▜█████▛▘\n ▘▘ ▝▝',
536
+ tags: false,
537
+ style: { fg: 'red', bold: true }, // ansi redBright = official Clawd orange
538
+ });
539
+ // Token chart (fills 30% of remaining)
540
+ const tokenChart = contrib.line({
541
+ top: layout.chart.top, left: 0, width: W, height: layout.chart.height,
542
+ label: ' Tokens/Turn (K) ',
543
+ showLegend: true,
544
+ legend: { width: 8 },
545
+ style: {
546
+ line: 'green',
547
+ text: 'white',
548
+ baseline: 'white',
549
+ border: { fg: 'cyan' },
550
+ },
551
+ border: { type: 'line', fg: 'cyan' },
552
+ xLabelPadding: 0,
553
+ xPadding: 1,
554
+ wholeNumbersOnly: false,
555
+ });
556
+ // Turn table — manual rendering for full-width columns + dim dividers
557
+ const turnBox = blessed.box({
558
+ top: layout.table.top, left: 0, width: W, height: layout.table.height,
559
+ content: '',
560
+ tags: true,
561
+ scrollable: true,
562
+ alwaysScroll: true,
563
+ scrollbar: { ch: '│', style: { fg: 'cyan' } },
564
+ keys: true, // Enable keyboard scrolling
565
+ vi: true, // Enable vi-style keys (j/k for scroll)
566
+ mouse: false, // Mouse disabled (use keyboard for scrolling, allows text selection)
567
+ input: true,
568
+ interactive: true, // Make box interactive for scrolling
569
+ label: ' Turns (scroll: ↑↓/k/j, page: PgUp/u, home/end: g/G) ',
570
+ border: { type: 'line', fg: 'cyan' },
571
+ style: { fg: 'white', border: { fg: 'cyan' } },
572
+ });
573
+ // Usage window (rate limits)
574
+ const windowBox = blessed.box({
575
+ top: layout.usage.top, left: 0, width: W, height: layout.usage.height,
576
+ content: ' {gray-fg}Loading usage...{/gray-fg}',
577
+ tags: true,
578
+ style: { fg: 'white', border: { fg: 'magenta' } },
579
+ border: { type: 'line' },
580
+ label: ' Rate Limit ',
581
+ });
582
+ // Footer (totals + routing + keys)
583
+ const footerBox = blessed.box({
584
+ top: layout.footer.top, left: 0, width: W, height: layout.footer.height,
585
+ content: ' {gray-fg}Loading...{/gray-fg}',
586
+ tags: true,
587
+ style: { fg: 'white', border: { fg: 'cyan' } },
588
+ border: { type: 'line' },
589
+ label: ' Session ',
590
+ });
591
+ // Add all widgets to screen
592
+ screen.append(headerBox);
593
+ screen.append(contextBox);
594
+ screen.append(tokenChart);
595
+ screen.append(turnBox);
596
+ screen.append(windowBox);
597
+ screen.append(footerBox);
598
+ // Apply layout positions to all widgets
599
+ function applyLayout() {
600
+ layout = calcLayout();
601
+ const fullWidth = Math.max(20, screen.width || 80);
602
+ const H_PAD = 1; // Keep dashboard widgets off the pane edges
603
+ const contentWidth = Math.max(18, fullWidth - (H_PAD * 2));
604
+ headerBox.top = layout.header.top;
605
+ headerBox.left = H_PAD;
606
+ headerBox.width = contentWidth;
607
+ headerBox.height = layout.header.height;
608
+ contextBox.top = layout.context.top;
609
+ contextBox.left = H_PAD;
610
+ contextBox.width = contentWidth;
611
+ contextBox.height = layout.context.height;
612
+ tokenChart.top = layout.chart.top;
613
+ tokenChart.left = H_PAD;
614
+ tokenChart.width = contentWidth;
615
+ tokenChart.height = layout.chart.height;
616
+ turnBox.top = layout.table.top;
617
+ turnBox.left = H_PAD;
618
+ turnBox.width = contentWidth;
619
+ turnBox.height = layout.table.height;
620
+ windowBox.top = layout.usage.top;
621
+ windowBox.left = H_PAD;
622
+ windowBox.width = contentWidth;
623
+ windowBox.height = layout.usage.height;
624
+ footerBox.top = layout.footer.top;
625
+ footerBox.left = H_PAD;
626
+ footerBox.width = contentWidth;
627
+ footerBox.height = layout.footer.height;
628
+ }
629
+ // Track geometry so we can re-anchor widgets even if tmux resize events are flaky
630
+ let lastLayoutW = screen.width || 0;
631
+ let lastLayoutH = screen.height || 0;
632
+ function ensureLayoutSynced() {
633
+ const w = screen.width || 0;
634
+ const h = screen.height || 0;
635
+ if (w === lastLayoutW && h === lastLayoutH)
636
+ return;
637
+ lastLayoutW = w;
638
+ lastLayoutH = h;
639
+ try {
640
+ screen.realloc?.();
641
+ }
642
+ catch { }
643
+ applyLayout();
644
+ }
645
+ // ── Logo color wave animation ──
646
+ let waveOffset = 0;
647
+ let sparkleTimer;
648
+ function renderLogoWave() {
649
+ try {
650
+ ensureLayoutSynced();
651
+ // Color wave in the border label
652
+ const coloredChars = LOGO_CHARS.map((ch, i) => {
653
+ const colorIdx = (i + waveOffset) % WAVE_COLORS.length;
654
+ return `{${WAVE_COLORS[colorIdx]}-fg}${ch}{/${WAVE_COLORS[colorIdx]}-fg}`;
655
+ });
656
+ // Logo left + session name right in border label
657
+ const logoStr = ` ${coloredChars.join('')} `;
658
+ // Session name with traveling shimmer across ALL characters
659
+ const SESSION_GLOW = ['white', 'cyan', 'magenta'];
660
+ const glowPos = (waveOffset * 2) % sessionName.length; // 2x speed for snappier travel
661
+ const nameChars = sessionName.split('').map((ch, i) => {
662
+ const dist = Math.min(Math.abs(i - glowPos), sessionName.length - Math.abs(i - glowPos)); // wrapping distance
663
+ if (dist === 0)
664
+ return `{${SESSION_GLOW[0]}-fg}${ch}{/${SESSION_GLOW[0]}-fg}`;
665
+ if (dist <= 2)
666
+ return `{${SESSION_GLOW[1]}-fg}${ch}{/${SESSION_GLOW[1]}-fg}`;
667
+ return `{${SESSION_GLOW[2]}-fg}${ch}{/${SESSION_GLOW[2]}-fg}`;
668
+ });
669
+ const sessionStr = ` ${nameChars.join('')} `;
670
+ const rawLogoLen = LOGO_CHARS.length + 2; // " ekkOS_ " = 8
671
+ const rawSessionLen = sessionName.length + 3; // " name " + 1 extra space before ┐
672
+ const boxW = Math.max(10, headerBox.width - 2); // minus border chars
673
+ const pad = Math.max(1, boxW - rawLogoLen - rawSessionLen);
674
+ headerBox.setLabel(logoStr + '─'.repeat(pad) + sessionStr);
675
+ waveOffset = (waveOffset + 1) % WAVE_COLORS.length;
676
+ // Stats go inside the box
677
+ const data = lastData;
678
+ if (data) {
679
+ const m = data.model.replace('claude-', '').replace(/-\d{8}$/, '');
680
+ headerBox.setContent(` {green-fg}$${data.totalCost.toFixed(2)}{/green-fg} T${data.turnCount} ${data.duration} $${data.avgCostPerTurn.toFixed(2)}/t {cyan-fg}${m}{/cyan-fg}`);
681
+ }
682
+ screen.render();
683
+ }
684
+ catch { }
685
+ }
686
+ // Wave cycles every 200ms for smooth color sweep
687
+ function scheduleWave() {
688
+ sparkleTimer = setTimeout(() => {
689
+ renderLogoWave();
690
+ scheduleWave();
691
+ }, 200);
692
+ }
693
+ scheduleWave();
694
+ // ── Update function ──
695
+ let lastFileSize = 0;
696
+ let lastData = null;
697
+ let lastScrollPerc = 0; // Preserve scroll position across updates
698
+ const debugLog = path.join(os.homedir(), '.ekkos', 'dashboard.log');
699
+ function dlog(msg) {
700
+ try {
701
+ fs.appendFileSync(debugLog, `[${new Date().toISOString()}] ${msg}\n`);
702
+ }
703
+ catch { }
704
+ }
705
+ function updateDashboard() {
706
+ ensureLayoutSynced();
707
+ // Resolve "initializing" → real session name from JSONL UUID filename
708
+ if (sessionName === 'initializing' || sessionName === 'session') {
709
+ try {
710
+ const basename = path.basename(jsonlPath, '.jsonl');
711
+ // JSONL filename is the session UUID (e.g., 607bd8e4-0a04-4db2-acf5-3f794be0f956.jsonl)
712
+ if (/^[0-9a-f]{8}-/.test(basename)) {
713
+ sessionName = (0, state_js_1.uuidToWords)(basename);
714
+ screen.title = `ekkOS - ${sessionName}`;
715
+ }
716
+ }
717
+ catch { }
718
+ }
719
+ let data;
720
+ try {
721
+ const stat = fs.statSync(jsonlPath);
722
+ if (stat.size === lastFileSize && lastData)
723
+ return;
724
+ lastFileSize = stat.size;
725
+ data = parseJsonlFile(jsonlPath, sessionName);
726
+ lastData = data;
727
+ }
728
+ catch (err) {
729
+ dlog(`Parse error: ${err.message}`);
730
+ return;
731
+ }
732
+ // ── Header — wave animation handles rendering, just trigger a frame ──
733
+ try {
734
+ renderLogoWave();
735
+ }
736
+ catch (err) {
737
+ dlog(`Header: ${err.message}`);
738
+ }
739
+ // ── Context + Cost (progress bar + stats, 3 content lines) ──
740
+ try {
741
+ const ctxPct = Math.min(data.currentContextPct, 100);
742
+ const ctxColor = ctxPct < 50 ? 'green' : ctxPct < 80 ? 'yellow' : 'red';
743
+ const tokensK = Math.round(data.currentContextTokens / 1000);
744
+ const maxK = Math.round(data.modelContextSize / 1000);
745
+ // Visual progress bar (fills available width)
746
+ const contextInnerWidth = Math.max(10, contextBox.width - 2);
747
+ // Extend bar slightly closer to mascot while keeping a small visual gap.
748
+ const barWidth = Math.max(10, contextInnerWidth - 4 - CLAWD_W);
749
+ const filled = Math.round((ctxPct / 100) * barWidth);
750
+ const bar = `{${ctxColor}-fg}${'█'.repeat(filled)}{/${ctxColor}-fg}${'░'.repeat(barWidth - filled)}`;
751
+ // Cost breakdown (use actual model pricing, not hardcoded)
752
+ const p = getModelPricing(data.model);
753
+ const rd = (data.totalCacheRead / 1000000) * p.cacheRead;
754
+ const wr = (data.totalCacheCreate / 1000000) * p.cacheWrite;
755
+ const ou = (data.totalOutput / 1000000) * p.output;
756
+ // Cache stats
757
+ const hitColor = data.cacheHitRate >= 80 ? 'green' : data.cacheHitRate >= 50 ? 'yellow' : 'red';
758
+ const cappedMax = Math.min(data.maxContextPct, 100);
759
+ contextBox.setContent(` ${bar}\n` +
760
+ ` {${ctxColor}-fg}${ctxPct.toFixed(0)}%{/${ctxColor}-fg} ${tokensK}K/${maxK}K` +
761
+ ` {green-fg}Read{/green-fg} $${rd.toFixed(2)}` +
762
+ ` {yellow-fg}Write{/yellow-fg} $${wr.toFixed(2)}` +
763
+ ` {cyan-fg}Output{/cyan-fg} $${ou.toFixed(2)}\n` +
764
+ ` {${hitColor}-fg}${data.cacheHitRate.toFixed(0)}% cache{/${hitColor}-fg}` +
765
+ ` peak:${cappedMax.toFixed(0)}%` +
766
+ ` avg:$${data.avgCostPerTurn.toFixed(2)}/t` +
767
+ ` replay A:${data.replayAppliedCount}` +
768
+ ` SZ:${data.replaySkippedSizeCount}` +
769
+ ` ST:${data.replaySkipStoreCount}`);
770
+ }
771
+ catch (err) {
772
+ dlog(`Context: ${err.message}`);
773
+ }
774
+ // ── Token Line Chart (all 3 series: Rd, Wr, Out) ──
775
+ try {
776
+ const recent = data.turns.slice(-30);
777
+ if (recent.length >= 2) {
778
+ const x = recent.map(t => String(t.turn));
779
+ tokenChart.setData([
780
+ { title: 'Rd', x, y: recent.map(t => Math.round(t.cacheRead / 1000)), style: { line: 'green' } },
781
+ { title: 'Wr', x, y: recent.map(t => Math.round(t.cacheCreate / 1000)), style: { line: 'yellow' } },
782
+ { title: 'Out', x, y: recent.map(t => Math.round(t.output / 1000)), style: { line: 'cyan' } },
783
+ ]);
784
+ }
785
+ }
786
+ catch (err) {
787
+ dlog(`Chart: ${err.message}`);
788
+ }
789
+ // ── Turn Table (manual rendering — dynamic widths + dim dividers) ──
790
+ try {
791
+ // Preserve scroll position BEFORE updating content
792
+ lastScrollPerc = turnBox.getScrollPerc();
793
+ // Account for borders + scrollbar gutter + padding so last column never wraps
794
+ const w = Math.max(18, turnBox.width - 5); // usable content width
795
+ const div = '{gray-fg}│{/gray-fg}';
796
+ function pad(s, width) {
797
+ if (s.length >= width)
798
+ return s.slice(0, width);
799
+ return s + ' '.repeat(width - s.length);
800
+ }
801
+ function rpad(s, width) {
802
+ if (s.length >= width)
803
+ return s.slice(0, width);
804
+ return ' '.repeat(width - s.length) + s;
805
+ }
806
+ // Data rows — RENDER ALL TURNS for full scrollback history
807
+ // Don't slice to visibleRows only — let user scroll through entire session
808
+ const turns = data.turns.slice().reverse();
809
+ let header = '';
810
+ let separator = '';
811
+ let rows = [];
812
+ // Responsive table layouts keep headers visible even in very narrow panes.
813
+ if (w >= 44) {
814
+ // Full mode: Turn, Model, Context, Cache Rd, Cache Wr, Output, Cost
815
+ const colNum = 4;
816
+ const colM = 6;
817
+ const colCtx = 6;
818
+ const colCost = 6;
819
+ const nDividers = 6;
820
+ const fixedW = colNum + colM + colCtx + colCost;
821
+ const flexTotal = w - fixedW - nDividers;
822
+ const rdW = Math.max(4, Math.floor(flexTotal * 0.35));
823
+ const wrW = Math.max(4, Math.floor(flexTotal * 0.30));
824
+ const outW = Math.max(4, flexTotal - rdW - wrW);
825
+ header = `{bold}${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}`;
826
+ separator = `{gray-fg}${'─'.repeat(colNum)}┼${'─'.repeat(colM)}┼${'─'.repeat(colCtx)}┼${'─'.repeat(rdW)}┼${'─'.repeat(wrW)}┼${'─'.repeat(outW)}┼${'─'.repeat(colCost)}{/gray-fg}`;
827
+ rows = turns.map(t => {
828
+ const mTag = modelTag(t.routedModel);
829
+ const mColor = t.routedModel.includes('haiku') ? 'green' : t.routedModel.includes('sonnet') ? 'blue' : 'magenta';
830
+ const costFlag = t.cost > 1.0 ? '{red-fg}' : t.cost > 0.5 ? '{yellow-fg}' : '{white-fg}';
831
+ const costEnd = t.cost > 1.0 ? '{/red-fg}' : t.cost > 0.5 ? '{/yellow-fg}' : '{/white-fg}';
832
+ return (pad(String(t.turn), colNum) + div +
833
+ `{${mColor}-fg}${pad(mTag, colM)}{/${mColor}-fg}` + div +
834
+ pad(`${t.contextPct.toFixed(0)}%`, colCtx) + div +
835
+ `{green-fg}${rpad(fmtK(t.cacheRead), rdW)}{/green-fg}` + div +
836
+ `{yellow-fg}${rpad(fmtK(t.cacheCreate), wrW)}{/yellow-fg}` + div +
837
+ `{cyan-fg}${rpad(fmtK(t.output), outW)}{/cyan-fg}` + div +
838
+ costFlag + rpad(`$${t.cost.toFixed(2)}`, colCost) + costEnd);
839
+ });
840
+ }
841
+ else if (w >= 30) {
842
+ // Compact mode: drop cache split columns to preserve readable headers.
843
+ const colNum = 4;
844
+ const colM = 6;
845
+ const colCtx = 6;
846
+ const colCost = 6;
847
+ const nDividers = 4;
848
+ const outW = Math.max(4, w - (colNum + colM + colCtx + colCost + nDividers));
849
+ header = `{bold}${pad('Turn', colNum)}${div}${pad('Model', colM)}${div}${pad('Context', colCtx)}${div}${rpad('Output', outW)}${div}${rpad('Cost', colCost)}{/bold}`;
850
+ separator = `{gray-fg}${'─'.repeat(colNum)}┼${'─'.repeat(colM)}┼${'─'.repeat(colCtx)}┼${'─'.repeat(outW)}┼${'─'.repeat(colCost)}{/gray-fg}`;
851
+ rows = turns.map(t => {
852
+ const mTag = modelTag(t.routedModel);
853
+ const mColor = t.routedModel.includes('haiku') ? 'green' : t.routedModel.includes('sonnet') ? 'blue' : 'magenta';
854
+ const costFlag = t.cost > 1.0 ? '{red-fg}' : t.cost > 0.5 ? '{yellow-fg}' : '{white-fg}';
855
+ const costEnd = t.cost > 1.0 ? '{/red-fg}' : t.cost > 0.5 ? '{/yellow-fg}' : '{/white-fg}';
856
+ return (pad(String(t.turn), colNum) + div +
857
+ `{${mColor}-fg}${pad(mTag, colM)}{/${mColor}-fg}` + div +
858
+ pad(`${t.contextPct.toFixed(0)}%`, colCtx) + div +
859
+ `{cyan-fg}${rpad(fmtK(t.output), outW)}{/cyan-fg}` + div +
860
+ costFlag + rpad(`$${t.cost.toFixed(2)}`, colCost) + costEnd);
861
+ });
862
+ }
863
+ else {
864
+ // Minimal mode: guaranteed no-wrap fallback.
865
+ const colNum = 4;
866
+ const colCtx = 6;
867
+ const colCost = 6;
868
+ header = `{bold}${pad('Turn', colNum)}${div}${pad('Context', colCtx)}${div}${rpad('Cost', colCost)}{/bold}`;
869
+ separator = `{gray-fg}${'─'.repeat(colNum)}┼${'─'.repeat(colCtx)}┼${'─'.repeat(colCost)}{/gray-fg}`;
870
+ rows = turns.map(t => {
871
+ const costFlag = t.cost > 1.0 ? '{red-fg}' : t.cost > 0.5 ? '{yellow-fg}' : '{white-fg}';
872
+ const costEnd = t.cost > 1.0 ? '{/red-fg}' : t.cost > 0.5 ? '{/yellow-fg}' : '{/white-fg}';
873
+ return (pad(String(t.turn), colNum) + div +
874
+ pad(`${t.contextPct.toFixed(0)}%`, colCtx) + div +
875
+ costFlag + rpad(`$${t.cost.toFixed(2)}`, colCost) + costEnd);
876
+ });
877
+ }
878
+ const lines = [header, separator, ...rows];
879
+ turnBox.setContent(lines.join('\n'));
880
+ // Restore scroll position AFTER content update
881
+ // Only restore if user has scrolled away from top (0%)
882
+ if (lastScrollPerc > 0) {
883
+ turnBox.setScrollPerc(lastScrollPerc);
884
+ }
885
+ }
886
+ catch (err) {
887
+ dlog(`Table: ${err.message}`);
888
+ }
889
+ // ── Session Totals (footer) ──
890
+ try {
891
+ const totalTokensM = ((data.totalCacheRead + data.totalCacheCreate + data.totalOutput) / 1000000).toFixed(2);
892
+ const totalSavings = data.turns.reduce((s, t) => s + t.savings, 0);
893
+ // Model routing breakdown (uses routedModel for actual model counts)
894
+ const opusCount = data.turns.filter(t => t.routedModel.includes('opus')).length;
895
+ const sonnetCount = data.turns.filter(t => t.routedModel.includes('sonnet')).length;
896
+ const haikuCount = data.turns.filter(t => t.routedModel.includes('haiku')).length;
897
+ const routingParts = [`{magenta-fg}O{/magenta-fg}:${opusCount}`];
898
+ if (sonnetCount > 0)
899
+ routingParts.push(`{blue-fg}S{/blue-fg}:${sonnetCount}`);
900
+ routingParts.push(`{green-fg}H{/green-fg}:${haikuCount}`);
901
+ const routingStr = routingParts.join(' ');
902
+ const savingsStr = totalSavings > 0
903
+ ? ` {green-fg}saved $${totalSavings.toFixed(2)}{/green-fg}`
904
+ : '';
905
+ footerBox.setContent(` {green-fg}$${data.totalCost.toFixed(2)}{/green-fg}` +
906
+ ` ${totalTokensM}M` +
907
+ ` ${routingStr}` +
908
+ ` R[A:${data.replayAppliedCount} SZ:${data.replaySkippedSizeCount} ST:${data.replaySkipStoreCount}]` +
909
+ savingsStr +
910
+ ` {gray-fg}? help q quit r refresh{/gray-fg}`);
911
+ }
912
+ catch (err) {
913
+ dlog(`Footer: ${err.message}`);
914
+ }
915
+ try {
916
+ screen.render();
917
+ }
918
+ catch (err) {
919
+ dlog(`Render: ${err.message}`);
920
+ }
921
+ }
922
+ // ── Usage window update — calls Anthropic's OAuth usage API ──
923
+ /**
924
+ * Fetch real usage limits from Anthropic's OAuth usage endpoint.
925
+ * Returns { five_hour: { utilization, resets_at }, seven_day: { utilization, resets_at } }
926
+ * Requires Claude Code OAuth token from macOS Keychain.
927
+ */
928
+ async function fetchAnthropicUsage() {
929
+ try {
930
+ const { execSync } = require('child_process');
931
+ const credsJson = execSync('security find-generic-password -s "Claude Code-credentials" -w', { encoding: 'utf-8', timeout: 5000 }).trim();
932
+ const creds = JSON.parse(credsJson);
933
+ const token = creds?.claudeAiOauth?.accessToken;
934
+ if (!token)
935
+ return null;
936
+ const resp = await fetch('https://api.anthropic.com/api/oauth/usage', {
937
+ headers: {
938
+ 'Authorization': `Bearer ${token}`,
939
+ 'anthropic-beta': 'oauth-2025-04-20',
940
+ 'Content-Type': 'application/json',
941
+ 'User-Agent': 'ekkos-cli/dashboard',
942
+ },
943
+ });
944
+ if (!resp.ok)
945
+ return null;
946
+ return await resp.json();
947
+ }
948
+ catch {
949
+ return null;
950
+ }
951
+ }
952
+ async function updateWindowBox() {
953
+ try {
954
+ const usage = await fetchAnthropicUsage();
955
+ let line1 = ' {gray-fg}No usage data{/gray-fg}';
956
+ let line2 = '';
957
+ // ── 5h Window (from Anthropic OAuth API) ──
958
+ if (usage?.five_hour) {
959
+ const pct = usage.five_hour.utilization;
960
+ const resetAt = new Date(usage.five_hour.resets_at).getTime();
961
+ const remainMs = Math.max(0, resetAt - Date.now());
962
+ const remainMin = Math.round(remainMs / 60000);
963
+ const rH = Math.floor(remainMin / 60);
964
+ const rM = remainMin % 60;
965
+ const pctColor = pct < 50 ? 'green' : pct < 80 ? 'yellow' : 'red';
966
+ const timeColor = remainMin > 120 ? 'green' : remainMin > 60 ? 'yellow' : 'red';
967
+ line1 = ` {bold}5h:{/bold}` +
968
+ ` {${pctColor}-fg}${pct.toFixed(0)}% used{/${pctColor}-fg}` +
969
+ ` {${timeColor}-fg}${rH}h${rM}m left{/${timeColor}-fg}`;
970
+ }
971
+ // ── Weekly (from Anthropic OAuth API) ──
972
+ if (usage?.seven_day) {
973
+ const pct = usage.seven_day.utilization;
974
+ const resetAt = new Date(usage.seven_day.resets_at);
975
+ const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
976
+ const resetDay = dayNames[resetAt.getDay()];
977
+ const resetHour = resetAt.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
978
+ const pctColor = pct < 50 ? 'green' : pct < 80 ? 'yellow' : 'red';
979
+ line2 = ` {bold}Week:{/bold}` +
980
+ ` {${pctColor}-fg}${pct.toFixed(0)}% used{/${pctColor}-fg}` +
981
+ ` resets ${resetDay} ${resetHour}`;
982
+ }
983
+ windowBox.setContent(line1 + (line2 ? '\n' + line2 : ''));
984
+ }
985
+ catch (err) {
986
+ dlog(`Window: ${err.message}`);
987
+ windowBox.setContent(` {gray-fg}Usage data unavailable{/gray-fg}`);
988
+ }
989
+ try {
990
+ screen.render();
991
+ }
992
+ catch { }
993
+ }
994
+ // ── Handle terminal resize ──
995
+ // Recalculate all widget positions from new screen.height
996
+ screen.on('resize', () => {
997
+ try {
998
+ ensureLayoutSynced();
999
+ if (lastData)
1000
+ updateDashboard();
1001
+ else
1002
+ screen.render();
1003
+ }
1004
+ catch (err) {
1005
+ dlog(`Resize: ${err.message}`);
1006
+ }
1007
+ });
1008
+ // ══════════════════════════════════════════════════════════════════════════
1009
+ // KEYBOARD SHORTCUTS - Only capture when dashboard pane has focus
1010
+ // In tmux split mode, this prevents capturing keys from Claude Code pane
1011
+ // ══════════════════════════════════════════════════════════════════════════
1012
+ screen.key(['q', 'C-c'], () => {
1013
+ clearInterval(pollInterval);
1014
+ clearInterval(windowPollInterval);
1015
+ clearTimeout(sparkleTimer);
1016
+ screen.destroy();
1017
+ process.exit(0);
1018
+ });
1019
+ screen.key(['r'], () => {
1020
+ lastFileSize = 0;
1021
+ updateDashboard();
1022
+ updateWindowBox();
1023
+ });
1024
+ // ══════════════════════════════════════════════════════════════════════════
1025
+ // FOCUS MANAGEMENT: In tmux split mode, DON'T auto-focus the turnBox
1026
+ // This prevents the dashboard from stealing focus from Claude Code on startup
1027
+ // User can manually focus by clicking into the dashboard pane
1028
+ // ══════════════════════════════════════════════════════════════════════════
1029
+ // Check if we're in a tmux session
1030
+ const inTmux = process.env.TMUX !== undefined;
1031
+ if (!inTmux) {
1032
+ // Only auto-focus when running standalone (not in tmux split)
1033
+ turnBox.focus();
1034
+ }
1035
+ // Scroll controls for turn table
1036
+ screen.key(['up', 'k'], () => {
1037
+ turnBox.scroll(-1);
1038
+ screen.render();
1039
+ });
1040
+ screen.key(['down', 'j'], () => {
1041
+ turnBox.scroll(1);
1042
+ screen.render();
1043
+ });
1044
+ screen.key(['pageup', 'u'], () => {
1045
+ turnBox.scroll(-(turnBox.height - 2));
1046
+ screen.render();
1047
+ });
1048
+ screen.key(['pagedown', 'd'], () => {
1049
+ turnBox.scroll((turnBox.height - 2));
1050
+ screen.render();
1051
+ });
1052
+ screen.key(['home', 'g'], () => {
1053
+ turnBox.setScrollPerc(0);
1054
+ screen.render();
1055
+ });
1056
+ screen.key(['end', 'G'], () => {
1057
+ turnBox.setScrollPerc(100);
1058
+ screen.render();
1059
+ });
1060
+ screen.key(['?', 'h'], () => {
1061
+ // Quick help overlay
1062
+ const help = blessed.box({
1063
+ top: 'center',
1064
+ left: 'center',
1065
+ width: 50,
1066
+ height: 16,
1067
+ content: ('{bold}Navigation{/bold}\n' +
1068
+ ' ↑/k/j/↓ Scroll line\n' +
1069
+ ' PgUp/u Scroll page up\n' +
1070
+ ' PgDn/d Scroll page down\n' +
1071
+ ' g/Home Scroll to top\n' +
1072
+ ' G/End Scroll to bottom\n' +
1073
+ '\n' +
1074
+ '{bold}Controls{/bold}\n' +
1075
+ ' r Refresh now\n' +
1076
+ ' q/Ctrl+C Quit\n' +
1077
+ '\n' +
1078
+ '{gray-fg}Press any key to close{/gray-fg}'),
1079
+ tags: true,
1080
+ border: 'line',
1081
+ style: { border: { fg: 'cyan' } },
1082
+ padding: 1,
1083
+ });
1084
+ screen.append(help);
1085
+ screen.render();
1086
+ // Close on any key press
1087
+ const closeHelp = () => {
1088
+ help.destroy();
1089
+ screen.render();
1090
+ screen.removeListener('key', closeHelp);
1091
+ };
1092
+ screen.on('key', closeHelp);
1093
+ });
1094
+ // Clear terminal buffer — prevents garbage text from previous commands
1095
+ screen.program.clear();
1096
+ // Dashboard is fully passive — no widget captures keyboard input
1097
+ updateDashboard();
1098
+ screen.render();
1099
+ // Delay first ccusage call — let blessed render first, then load heavy data
1100
+ setTimeout(() => updateWindowBox(), 2000);
1101
+ const pollInterval = setInterval(updateDashboard, refreshMs);
1102
+ const windowPollInterval = setInterval(updateWindowBox, 15000); // every 15s
1103
+ }
1104
+ // ── Helpers ──
1105
+ function fmtK(n) {
1106
+ if (n >= 1000000)
1107
+ return `${(n / 1000000).toFixed(1)}M`;
1108
+ if (n >= 1000)
1109
+ return `${(n / 1000).toFixed(1)}K`;
1110
+ return String(n);
1111
+ }
1112
+ function formatK(n) { return fmtK(n); }
1113
+ // ── Session picker ──
1114
+ async function pickSession() {
1115
+ const sessions = await (0, usage_parser_js_1.listEkkosSessions)(20);
1116
+ if (sessions.length === 0) {
1117
+ console.log(chalk_1.default.yellow('No sessions found with usage data.'));
1118
+ console.log(chalk_1.default.gray('Start a session with "ekkos run" first.'));
1119
+ return null;
1120
+ }
1121
+ const { default: inquirer } = await Promise.resolve().then(() => __importStar(require('inquirer')));
1122
+ const answer = await inquirer.prompt([
1123
+ {
1124
+ type: 'list',
1125
+ name: 'session',
1126
+ message: 'Choose session:',
1127
+ choices: sessions.map(s => ({
1128
+ name: `${s.name} ($${s.cost.toFixed(2)}, ${s.turnCount}t)`,
1129
+ value: s.name,
1130
+ })),
1131
+ },
1132
+ ]);
1133
+ return answer.session;
1134
+ }
1135
+ // ── Commander command ──
1136
+ exports.dashboardCommand = new commander_1.Command('dashboard')
1137
+ .description('Live TUI dashboard for monitoring Claude Code session usage')
1138
+ .argument('[session-name]', 'ekkOS session name (e.g., dew-pod-hum)')
1139
+ .option('--latest', 'Auto-detect latest active session')
1140
+ .option('--wait-for-new', 'Wait for a new session to start (used by ekkos run --dashboard)')
1141
+ .option('--refresh <ms>', 'Polling interval in ms', '2000')
1142
+ .option('--compact', 'Minimal layout for small terminals')
1143
+ .action(async (sessionNameArg, options) => {
1144
+ const refreshMs = parseInt(options.refresh) || 2000;
1145
+ // --wait-for-new: poll until a brand new session appears
1146
+ if (options.waitForNew) {
1147
+ const { sessionName, jsonlPath } = await waitForNewSession();
1148
+ await launchDashboard(sessionName, jsonlPath, refreshMs);
1149
+ return;
1150
+ }
1151
+ let sessionName = null;
1152
+ if (sessionNameArg) {
1153
+ sessionName = sessionNameArg;
1154
+ }
1155
+ else if (options.latest) {
1156
+ sessionName = getLatestSession();
1157
+ if (!sessionName) {
1158
+ console.log(chalk_1.default.yellow('No active sessions found.'));
1159
+ process.exit(1);
1160
+ }
1161
+ console.log(chalk_1.default.gray(`Auto-detected: ${sessionName}`));
1162
+ }
1163
+ else {
1164
+ sessionName = await pickSession();
1165
+ if (!sessionName)
1166
+ process.exit(0);
1167
+ }
1168
+ const jsonlPath = resolveJsonlPath(sessionName);
1169
+ if (!jsonlPath) {
1170
+ console.log(chalk_1.default.red(`No JSONL found for "${sessionName}"`));
1171
+ console.log(chalk_1.default.gray('Run "ekkos sessions" to see active sessions'));
1172
+ process.exit(1);
1173
+ }
1174
+ await launchDashboard(sessionName, jsonlPath, refreshMs);
1175
+ });