@ekkos/cli 1.3.1 → 1.3.5

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 (131) hide show
  1. package/dist/capture/jsonl-rewriter.d.ts +1 -1
  2. package/dist/capture/jsonl-rewriter.js +3 -3
  3. package/dist/capture/transcript-repair.d.ts +2 -2
  4. package/dist/capture/transcript-repair.js +2 -2
  5. package/dist/commands/claw.d.ts +13 -0
  6. package/dist/commands/claw.js +253 -0
  7. package/dist/commands/dashboard.js +742 -118
  8. package/dist/commands/doctor.d.ts +3 -3
  9. package/dist/commands/doctor.js +6 -79
  10. package/dist/commands/gemini.d.ts +19 -0
  11. package/dist/commands/gemini.js +193 -0
  12. package/dist/commands/init.d.ts +1 -0
  13. package/dist/commands/init.js +56 -41
  14. package/dist/commands/run.d.ts +0 -1
  15. package/dist/commands/run.js +288 -263
  16. package/dist/commands/scan.d.ts +21 -0
  17. package/dist/commands/scan.js +386 -0
  18. package/dist/commands/status.d.ts +4 -1
  19. package/dist/commands/status.js +165 -27
  20. package/dist/commands/swarm-dashboard.js +156 -28
  21. package/dist/commands/swarm.d.ts +1 -1
  22. package/dist/commands/swarm.js +1 -1
  23. package/dist/commands/test-claude.d.ts +2 -2
  24. package/dist/commands/test-claude.js +3 -3
  25. package/dist/deploy/index.d.ts +0 -2
  26. package/dist/deploy/index.js +0 -2
  27. package/dist/deploy/settings.d.ts +6 -5
  28. package/dist/deploy/settings.js +64 -16
  29. package/dist/deploy/skills.js +1 -2
  30. package/dist/index.js +86 -96
  31. package/dist/lib/usage-parser.d.ts +1 -1
  32. package/dist/lib/usage-parser.js +9 -6
  33. package/dist/local/index.d.ts +14 -0
  34. package/dist/local/index.js +28 -0
  35. package/dist/local/local-embeddings.d.ts +49 -0
  36. package/dist/local/local-embeddings.js +232 -0
  37. package/dist/local/offline-fallback.d.ts +44 -0
  38. package/dist/local/offline-fallback.js +159 -0
  39. package/dist/local/sqlite-store.d.ts +126 -0
  40. package/dist/local/sqlite-store.js +393 -0
  41. package/dist/local/sync-engine.d.ts +42 -0
  42. package/dist/local/sync-engine.js +223 -0
  43. package/dist/utils/platform.d.ts +5 -1
  44. package/dist/utils/platform.js +24 -4
  45. package/dist/utils/proxy-url.d.ts +21 -0
  46. package/dist/utils/proxy-url.js +34 -0
  47. package/dist/utils/state.d.ts +1 -1
  48. package/dist/utils/state.js +11 -3
  49. package/dist/utils/templates.js +1 -1
  50. package/package.json +11 -4
  51. package/templates/CLAUDE.md +49 -107
  52. package/dist/agent/daemon.d.ts +0 -130
  53. package/dist/agent/daemon.js +0 -606
  54. package/dist/agent/health-check.d.ts +0 -35
  55. package/dist/agent/health-check.js +0 -243
  56. package/dist/agent/pty-runner.d.ts +0 -53
  57. package/dist/agent/pty-runner.js +0 -190
  58. package/dist/commands/agent.d.ts +0 -50
  59. package/dist/commands/agent.js +0 -544
  60. package/dist/commands/setup-remote.d.ts +0 -20
  61. package/dist/commands/setup-remote.js +0 -582
  62. package/dist/utils/verify-remote-terminal.d.ts +0 -10
  63. package/dist/utils/verify-remote-terminal.js +0 -415
  64. package/templates/README.md +0 -378
  65. package/templates/claude-plugins/PHASE2_COMPLETION.md +0 -346
  66. package/templates/claude-plugins/PLUGIN_PROPOSALS.md +0 -1776
  67. package/templates/claude-plugins/README.md +0 -587
  68. package/templates/claude-plugins/agents/code-reviewer.json +0 -14
  69. package/templates/claude-plugins/agents/debug-detective.json +0 -15
  70. package/templates/claude-plugins/agents/git-companion.json +0 -14
  71. package/templates/claude-plugins/blog-manager/.claude-plugin/plugin.json +0 -8
  72. package/templates/claude-plugins/blog-manager/commands/blog.md +0 -691
  73. package/templates/claude-plugins/golden-loop-monitor/.claude-plugin/plugin.json +0 -8
  74. package/templates/claude-plugins/golden-loop-monitor/commands/loop-status.md +0 -434
  75. package/templates/claude-plugins/learning-tracker/.claude-plugin/plugin.json +0 -8
  76. package/templates/claude-plugins/learning-tracker/commands/my-patterns.md +0 -282
  77. package/templates/claude-plugins/memory-lens/.claude-plugin/plugin.json +0 -8
  78. package/templates/claude-plugins/memory-lens/commands/memory-search.md +0 -181
  79. package/templates/claude-plugins/pattern-coach/.claude-plugin/plugin.json +0 -8
  80. package/templates/claude-plugins/pattern-coach/commands/forge.md +0 -365
  81. package/templates/claude-plugins/project-schema-validator/.claude-plugin/plugin.json +0 -8
  82. package/templates/claude-plugins/project-schema-validator/commands/validate-schema.md +0 -582
  83. package/templates/claude-plugins-admin/AGENT_TEAM_PROPOSALS.md +0 -819
  84. package/templates/claude-plugins-admin/README.md +0 -446
  85. package/templates/claude-plugins-admin/autonomous-admin-agent/.claude-plugin/plugin.json +0 -8
  86. package/templates/claude-plugins-admin/autonomous-admin-agent/commands/agent.md +0 -595
  87. package/templates/claude-plugins-admin/backend-agent/.claude-plugin/plugin.json +0 -8
  88. package/templates/claude-plugins-admin/backend-agent/commands/backend.md +0 -798
  89. package/templates/claude-plugins-admin/deploy-guardian/.claude-plugin/plugin.json +0 -8
  90. package/templates/claude-plugins-admin/deploy-guardian/commands/deploy.md +0 -554
  91. package/templates/claude-plugins-admin/frontend-agent/.claude-plugin/plugin.json +0 -8
  92. package/templates/claude-plugins-admin/frontend-agent/commands/frontend.md +0 -881
  93. package/templates/claude-plugins-admin/mcp-server-manager/.claude-plugin/plugin.json +0 -8
  94. package/templates/claude-plugins-admin/mcp-server-manager/commands/mcp.md +0 -85
  95. package/templates/claude-plugins-admin/memory-system-monitor/.claude-plugin/plugin.json +0 -8
  96. package/templates/claude-plugins-admin/memory-system-monitor/commands/memory-health.md +0 -569
  97. package/templates/claude-plugins-admin/qa-agent/.claude-plugin/plugin.json +0 -8
  98. package/templates/claude-plugins-admin/qa-agent/commands/qa.md +0 -863
  99. package/templates/claude-plugins-admin/tech-lead-agent/.claude-plugin/plugin.json +0 -8
  100. package/templates/claude-plugins-admin/tech-lead-agent/commands/lead.md +0 -732
  101. package/templates/commands/continue.md +0 -47
  102. package/templates/cursor-rules/ekkos-memory.md +0 -127
  103. package/templates/ekkos-manifest.json +0 -223
  104. package/templates/helpers/json-parse.cjs +0 -101
  105. package/templates/hooks-node/lib/state.js +0 -187
  106. package/templates/hooks-node/stop.js +0 -416
  107. package/templates/hooks-node/user-prompt-submit.js +0 -337
  108. package/templates/plan-template.md +0 -306
  109. package/templates/rules/00-hooks-contract.mdc +0 -89
  110. package/templates/rules/30-ekkos-core.mdc +0 -188
  111. package/templates/rules/31-ekkos-messages.mdc +0 -78
  112. package/templates/shared/hooks-enabled.json +0 -22
  113. package/templates/shared/session-words.json +0 -45
  114. package/templates/skills/ekkOS_Deep_Recall/Skill.md +0 -282
  115. package/templates/skills/ekkOS_Learn/Skill.md +0 -265
  116. package/templates/skills/ekkOS_Memory_First/Skill.md +0 -206
  117. package/templates/skills/ekkOS_Plan_Assist/Skill.md +0 -302
  118. package/templates/skills/ekkOS_Preferences/Skill.md +0 -247
  119. package/templates/skills/ekkOS_Reflect/Skill.md +0 -257
  120. package/templates/skills/ekkOS_Safety/Skill.md +0 -265
  121. package/templates/skills/ekkOS_Schema/Skill.md +0 -251
  122. package/templates/skills/ekkOS_Summary/Skill.md +0 -257
  123. package/templates/spec-template.md +0 -159
  124. package/templates/windsurf-rules/ekkos-memory.md +0 -127
  125. package/templates/windsurf-skills/README.md +0 -58
  126. package/templates/windsurf-skills/ekkos-continue/SKILL.md +0 -81
  127. package/templates/windsurf-skills/ekkos-golden-loop/SKILL.md +0 -225
  128. package/templates/windsurf-skills/ekkos-insights/SKILL.md +0 -138
  129. package/templates/windsurf-skills/ekkos-recall/SKILL.md +0 -96
  130. package/templates/windsurf-skills/ekkos-safety/SKILL.md +0 -89
  131. package/templates/windsurf-skills/ekkos-vault/SKILL.md +0 -86
@@ -71,11 +71,12 @@ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12
71
71
  // ── Pricing ──
72
72
  // Pricing per MTok from https://platform.claude.com/docs/en/about-claude/pricing
73
73
  const MODEL_PRICING = {
74
- 'claude-opus-4-6': { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.50 },
75
- 'claude-opus-4-5-20250620': { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.50 },
74
+ 'claude-opus-4-6': { input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.50 },
75
+ 'claude-opus-4-5-20250620': { input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.50 },
76
+ 'claude-sonnet-4-6': { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.30 },
76
77
  'claude-sonnet-4-5-20250929': { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.30 },
77
78
  'claude-sonnet-4-5-20250514': { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.30 },
78
- 'claude-haiku-4-5-20251001': { input: 0.80, output: 4, cacheWrite: 1.00, cacheRead: 0.08 },
79
+ 'claude-haiku-4-5-20251001': { input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.10 },
79
80
  };
80
81
  function getModelPricing(modelId) {
81
82
  if (MODEL_PRICING[modelId])
@@ -150,6 +151,21 @@ function parseJsonlFile(jsonlPath, sessionName) {
150
151
  }
151
152
  if (entry.type === 'assistant' && entry.message?.usage) {
152
153
  const msgId = entry.message.id;
154
+ const internalTurnType = typeof entry.message._ekkos_internal_turn === 'string'
155
+ ? entry.message._ekkos_internal_turn
156
+ : '';
157
+ const compactionSource = typeof entry.message._ekkos_compaction_source === 'string'
158
+ ? entry.message._ekkos_compaction_source
159
+ : '';
160
+ const isSyntheticCompactionMessage = typeof msgId === 'string' && msgId.startsWith('msg_ekkos_');
161
+ const isExplicitlyNonBillable = entry.message._ekkos_billable === false;
162
+ const isInternalCompactionTurn = internalTurnType === 'compaction' ||
163
+ isSyntheticCompactionMessage ||
164
+ compactionSource.length > 0 ||
165
+ isExplicitlyNonBillable;
166
+ if (isInternalCompactionTurn) {
167
+ continue;
168
+ }
153
169
  const isNew = msgId && !turnsByMsgId.has(msgId);
154
170
  const usage = entry.message.usage;
155
171
  model = entry.message.model || model;
@@ -199,6 +215,7 @@ function parseJsonlFile(jsonlPath, sessionName) {
199
215
  const turnData = {
200
216
  turn: turnNum,
201
217
  contextPct,
218
+ input: inputTokens,
202
219
  cacheRead: cacheReadTokens,
203
220
  cacheCreate: cacheCreationTokens,
204
221
  output: outputTokens,
@@ -225,6 +242,7 @@ function parseJsonlFile(jsonlPath, sessionName) {
225
242
  // Build ordered turns array from the Map (last-entry-wins dedup)
226
243
  const turns = msgIdOrder.map(id => turnsByMsgId.get(id));
227
244
  const totalCost = turns.reduce((s, t) => s + t.cost, 0);
245
+ const totalInput = turns.reduce((s, t) => s + t.input, 0);
228
246
  const totalCacheRead = turns.reduce((s, t) => s + t.cacheRead, 0);
229
247
  const totalCacheCreate = turns.reduce((s, t) => s + t.cacheCreate, 0);
230
248
  const totalOutput = turns.reduce((s, t) => s + t.output, 0);
@@ -247,7 +265,7 @@ function parseJsonlFile(jsonlPath, sessionName) {
247
265
  // Get current context tokens from the last turn's raw data
248
266
  const lastTurn = turns.length > 0 ? turns[turns.length - 1] : null;
249
267
  const currentContextTokens = lastTurn
250
- ? lastTurn.cacheRead + lastTurn.cacheCreate + (lastTurn.output || 0)
268
+ ? lastTurn.input + lastTurn.cacheRead + lastTurn.cacheCreate
251
269
  : 0;
252
270
  const modelContextSize = getModelCtxSize(model);
253
271
  return {
@@ -255,7 +273,8 @@ function parseJsonlFile(jsonlPath, sessionName) {
255
273
  model,
256
274
  turnCount: turns.length,
257
275
  totalCost,
258
- totalTokens: totalCacheRead + totalCacheCreate + totalOutput,
276
+ totalTokens: totalInput + totalCacheRead + totalCacheCreate + totalOutput,
277
+ totalInput,
259
278
  totalCacheRead,
260
279
  totalCacheCreate,
261
280
  totalOutput,
@@ -322,6 +341,17 @@ function displaySessionName(rawName) {
322
341
  return rawName;
323
342
  return resolveSessionAlias(rawName) || (0, state_js_1.uuidToWords)(rawName);
324
343
  }
344
+ function isStableSessionId(sessionId) {
345
+ return typeof sessionId === 'string' && UUID_REGEX.test(sessionId);
346
+ }
347
+ function isPendingSessionId(sessionId) {
348
+ if (typeof sessionId !== 'string')
349
+ return true;
350
+ const normalized = sessionId.trim().toLowerCase();
351
+ if (!normalized)
352
+ return true;
353
+ return normalized === 'pending' || normalized === '_pending' || normalized.startsWith('_pending-');
354
+ }
325
355
  // ── Resolve session to JSONL path ──
326
356
  function resolveJsonlPath(sessionName, createdAfterMs) {
327
357
  // 1) Try standard resolution (works when sessionId is a real UUID)
@@ -331,13 +361,25 @@ function resolveJsonlPath(sessionName, createdAfterMs) {
331
361
  if (fs.existsSync(jsonlPath))
332
362
  return jsonlPath;
333
363
  }
334
- // 2) Fallback: active session has "pending" UUID
364
+ // 2) Active-session fallback.
365
+ // Prefer exact sessionId lookup when available, otherwise fall through to
366
+ // findLatestJsonl with the createdAfterMs timestamp constraint (prevents
367
+ // cross-binding stale sessions from different runs).
335
368
  const activeSessionsPath = path.join(os.homedir(), '.ekkos', 'active-sessions.json');
336
369
  if (fs.existsSync(activeSessionsPath)) {
337
370
  try {
338
371
  const sessions = JSON.parse(fs.readFileSync(activeSessionsPath, 'utf-8'));
339
372
  const match = sessions.find((s) => s.sessionName === sessionName);
340
373
  if (match?.projectPath) {
374
+ if (isStableSessionId(match.sessionId)) {
375
+ // Prefer exact sessionId lookup, but if that file does not exist yet
376
+ // (or sessionId was pre-generated), fall back to latest project JSONL.
377
+ const bySessionId = findJsonlBySessionId(match.projectPath, match.sessionId);
378
+ if (bySessionId)
379
+ return bySessionId;
380
+ }
381
+ // Pending or unknown sessionId — fall through to timestamp-constrained
382
+ // latest-file lookup. The createdAfterMs guard prevents stale cross-binds.
341
383
  return findLatestJsonl(match.projectPath, createdAfterMs);
342
384
  }
343
385
  }
@@ -353,12 +395,15 @@ function resolveJsonlPath(sessionName, createdAfterMs) {
353
395
  function findLatestJsonl(projectPath, createdAfterMs) {
354
396
  // Claude encodes project paths by replacing separators with '-'.
355
397
  // On Windows, ':' is also illegal in directory names so it gets replaced too.
398
+ // Claude also replaces underscores and other non-alphanumeric chars with '-'.
356
399
  // Try all plausible encodings since Claude's exact scheme varies by platform.
357
400
  const candidateEncodings = new Set([
358
401
  projectPath.replace(/[\\/]/g, '-'), // C:-Users-name (backslash only)
359
402
  projectPath.replace(/[:\\/]/g, '-'), // C--Users-name (colon + backslash)
360
403
  '-' + projectPath.replace(/[:\\/]/g, '-'), // -C--Users-name (leading separator)
361
404
  projectPath.replace(/\//g, '-'), // macOS: /Users/name → -Users-name
405
+ projectPath.replace(/[^a-zA-Z0-9]/g, '-'), // Replace ALL non-alphanumeric (handles _)
406
+ `-${projectPath.replace(/^[\\/]+/, '').replace(/[^a-zA-Z0-9]/g, '-')}`, // Leading - variant
362
407
  ]);
363
408
  const projectsRoot = path.join(os.homedir(), '.claude', 'projects');
364
409
  for (const encoded of Array.from(candidateEncodings)) {
@@ -390,6 +435,8 @@ function findJsonlBySessionId(projectPath, sessionId) {
390
435
  projectPath.replace(/[:\\/]/g, '-'),
391
436
  '-' + projectPath.replace(/[:\\/]/g, '-'),
392
437
  projectPath.replace(/\//g, '-'),
438
+ projectPath.replace(/[^a-zA-Z0-9]/g, '-'),
439
+ `-${projectPath.replace(/^[\\/]+/, '').replace(/[^a-zA-Z0-9]/g, '-')}`,
393
440
  ]);
394
441
  const projectsRoot = path.join(os.homedir(), '.claude', 'projects');
395
442
  for (const encoded of Array.from(candidateEncodings)) {
@@ -436,8 +483,8 @@ async function waitForNewSession() {
436
483
  const hint = JSON.parse(fs.readFileSync(hintPath, 'utf-8'));
437
484
  if (hint.ts >= launchTs - 5000 && hint.sessionName && hint.projectPath) {
438
485
  candidateName = hint.sessionName;
439
- const jsonlPath = findJsonlBySessionId(hint.projectPath, hint.sessionId || '')
440
- || findLatestJsonl(hint.projectPath, launchTs)
486
+ const hintSessionId = typeof hint.sessionId === 'string' ? hint.sessionId : '';
487
+ const jsonlPath = (isStableSessionId(hintSessionId) ? findJsonlBySessionId(hint.projectPath, hintSessionId) : null)
441
488
  || resolveJsonlPath(hint.sessionName, launchTs);
442
489
  // Return immediately — JSONL may be null; dashboard will lazy-resolve
443
490
  console.log(chalk_1.default.green(` Found session (hook hint): ${hint.sessionName}`));
@@ -452,25 +499,21 @@ async function waitForNewSession() {
452
499
  const startedMs = new Date(s.startedAt).getTime();
453
500
  if (startedMs >= launchTs - 2000) {
454
501
  candidateName = s.sessionName;
455
- // Try to resolve JSONL immediately (may succeed if Claude already created it)
456
- let jsonlPath = resolveJsonlPath(s.sessionName, launchTs);
457
- if (!jsonlPath) {
458
- const allPaths = new Set(sessions.filter(x => x.sessionName === s.sessionName && x.projectPath)
459
- .map(x => x.projectPath));
460
- for (const pp of Array.from(allPaths)) {
461
- jsonlPath = findLatestJsonl(pp, launchTs);
462
- if (jsonlPath)
463
- break;
464
- }
465
- }
502
+ // Try exact sessionId lookup first, then fall back to name-based resolution.
503
+ let jsonlPath = (isStableSessionId(s.sessionId) && s.projectPath)
504
+ ? findJsonlBySessionId(s.projectPath, s.sessionId)
505
+ : null;
506
+ if (!jsonlPath)
507
+ jsonlPath = resolveJsonlPath(s.sessionName, launchTs);
466
508
  // Return immediately with session name — JSONL may still be null
467
509
  // (Claude Code hasn't created it yet). Dashboard will lazy-resolve.
468
- console.log(chalk_1.default.green(` Found session: ${s.sessionName}`));
510
+ console.log(chalk_1.default.green(` Found session: ${s.sessionName}${jsonlPath ? '' : ' (awaiting transcript)'}`));
469
511
  return { sessionName: s.sessionName, jsonlPath, launchCwd: s.projectPath || launchCwd, launchTs };
470
512
  }
471
513
  }
472
- // Fallback: use launch CWD to find any new JSONL
473
- if (launchCwd) {
514
+ // Fallback: use launch CWD to find any new JSONL only if we don't even
515
+ // have a candidate session name yet.
516
+ if (launchCwd && !candidateName) {
474
517
  const latestJsonl = findLatestJsonl(launchCwd, launchTs);
475
518
  if (latestJsonl) {
476
519
  const name = candidateName || path.basename(latestJsonl, '.jsonl');
@@ -478,8 +521,9 @@ async function waitForNewSession() {
478
521
  return { sessionName: name, jsonlPath: latestJsonl, launchCwd, launchTs };
479
522
  }
480
523
  }
481
- // Broad fallback: scan ALL project directories for any new JSONL
482
- {
524
+ // Broad fallback: scan ALL project directories for any new JSONL only
525
+ // when no candidate name is known.
526
+ if (!candidateName) {
483
527
  const projectsRoot = path.join(os.homedir(), '.claude', 'projects');
484
528
  try {
485
529
  if (fs.existsSync(projectsRoot)) {
@@ -580,7 +624,7 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
580
624
  mouse: false, // Disable ALL mouse capture (allows terminal text selection)
581
625
  grabKeys: false, // Don't grab keyboard input from other panes
582
626
  sendFocus: false, // Don't send focus events (breaks paste)
583
- ignoreLocked: ['C-c'], // Only capture Ctrl+C for quit
627
+ ignoreLocked: ['C-c', 'C-q', 'f7'], // Capture Ctrl+C, Ctrl+Q for quit, F7 for hard refresh
584
628
  input: ttyInput, // Use /dev/tty for input (isolated from stdout pipe)
585
629
  output: ttyOutput, // Use /dev/tty for output (isolated from stdout pipe)
586
630
  forceUnicode: true, // Better text rendering
@@ -598,24 +642,34 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
598
642
  // Override alternateBuffer to do nothing (tmux pane isolation)
599
643
  screen.program.alternateBuffer = () => { };
600
644
  screen.program.normalBuffer = () => { };
601
- // Don't enter raw mode let the OS handle it (tmux pane isolation)
645
+ // Override blessed's setRawMode so it doesn't toggle raw mode on its own
646
+ // schedule, but manually enable raw mode on the ttyInput so that function
647
+ // key escape sequences (F7, etc.) are delivered properly.
602
648
  if (screen.program.setRawMode) {
603
649
  screen.program.setRawMode = (enabled) => {
604
- // Silently ignore raw mode requests
650
+ // Silently ignore blessed's raw mode requests — we manage it ourselves
605
651
  return enabled;
606
652
  };
607
653
  }
654
+ // Manually enable raw mode on the dedicated /dev/tty stream.
655
+ // This is safe because ttyInput is a separate fd from stdout/stdin,
656
+ // so it only affects this tmux pane. Without raw mode, multi-byte
657
+ // escape sequences for function keys (F7 = \x1b[18~) are not delivered.
658
+ if (ttyInput.setRawMode) {
659
+ ttyInput.setRawMode(true);
660
+ }
608
661
  }
609
662
  // ── Zero-gap calculated layout ──
610
663
  //
611
664
  // Every widget has exact top + height computed from screen.height.
612
665
  // Fixed heights for header/context/usage/footer, remaining split
613
- // between chart (40%) and table (60%). No percentages = no gaps.
666
+ // between chart (~25-34%) and table (~66-75%) so turns get more room.
667
+ // No percentages = no gaps.
614
668
  //
615
669
  // header: 3 rows (session stats + animated logo)
616
670
  // context: 5 rows (progress bar + cost breakdown + cache stats)
617
- // chart: 40% of remaining (token usage graph)
618
- // table: 60% of remaining (turn-by-turn breakdown)
671
+ // chart: ~25-34% of remaining (token usage graph)
672
+ // table: ~66-75% of remaining (turn-by-turn breakdown)
619
673
  // usage: 3 rows (Anthropic rate limit window)
620
674
  // footer: 3 rows (totals + routing + keybindings)
621
675
  const LOGO_CHARS = ['e', 'k', 'k', 'O', 'S', '_'];
@@ -661,11 +715,20 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
661
715
  const FOOTER_H = 3;
662
716
  const CLAWD_W = 16; // Width reserved for Clawd mascot in context box
663
717
  const FIXED_H = HEADER_H + CONTEXT_H + USAGE_H + FOOTER_H; // 15
718
+ function resolveChartRatio(height) {
719
+ if (height >= 62)
720
+ return 0.25;
721
+ if (height >= 48)
722
+ return 0.28;
723
+ if (height >= 36)
724
+ return 0.30;
725
+ return 0.34;
726
+ }
664
727
  function calcLayout() {
665
- const H = screen.height;
666
- const remaining = Math.max(6, H - FIXED_H);
667
- const chartH = Math.max(8, Math.floor(remaining * 0.40));
668
- const tableH = Math.max(4, remaining - chartH);
728
+ const H = Math.max(24, screen.height || 24);
729
+ const remaining = Math.max(8, H - FIXED_H);
730
+ const chartH = Math.max(6, Math.floor(remaining * resolveChartRatio(H)));
731
+ const tableH = Math.max(5, remaining - chartH);
669
732
  return {
670
733
  header: { top: 0, height: HEADER_H },
671
734
  context: { top: HEADER_H, height: CONTEXT_H },
@@ -678,6 +741,7 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
678
741
  let layout = calcLayout();
679
742
  let lastFileSize = 0;
680
743
  let lastData = null;
744
+ let sessionStartMs = launchTs || null; // epoch ms of session start, for live timer
681
745
  let lastChartSeries = null;
682
746
  let lastScrollPerc = 0; // Preserve scroll position across updates
683
747
  let fortuneIdx = Math.floor(Math.random() * activeFortunes.length);
@@ -945,14 +1009,36 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
945
1009
  }
946
1010
  catch { }
947
1011
  }
1012
+ function renderPreTurnState() {
1013
+ try {
1014
+ contextBox.setContent(` {green-fg}Session active{/green-fg} {gray-fg}${sessionName}{/gray-fg}\n` +
1015
+ ` Token and cost metrics appear after the first assistant response.`);
1016
+ turnBox.setContent(`{bold}Turns{/bold}\n` +
1017
+ `{gray-fg}—{/gray-fg}`);
1018
+ const timerStr = sessionStartMs
1019
+ ? `{cyan-fg}${formatElapsed(sessionStartMs)}{/cyan-fg} `
1020
+ : '';
1021
+ footerBox.setLabel(` ${sessionName} `);
1022
+ footerBox.setContent(` ${timerStr}{green-fg}Ready{/green-fg}` +
1023
+ (inTmux
1024
+ ? ` {gray-fg}Ctrl+Q quit F7 redraw{/gray-fg}`
1025
+ : ` {gray-fg}? help q quit r refresh F7 redraw{/gray-fg}`));
1026
+ }
1027
+ catch (err) {
1028
+ dlog(`Pre-turn render: ${err.message}`);
1029
+ }
1030
+ }
948
1031
  function updateDashboard() {
949
1032
  ensureLayoutSynced();
950
1033
  // ── Lazy JSONL resolution ──────────────────────────────────────────────
951
1034
  // Dashboard may launch before Claude Code creates the JSONL file.
952
1035
  // Keep trying to find it on each poll tick.
953
1036
  if (!jsonlPath || !fs.existsSync(jsonlPath)) {
1037
+ const shouldUseCwdFallback = initialSessionName === 'initializing'
1038
+ || initialSessionName === 'session'
1039
+ || UUID_REGEX.test(initialSessionName);
954
1040
  const resolved = resolveJsonlPath(initialSessionName, launchTs)
955
- || (launchCwd ? findLatestJsonl(launchCwd, launchTs) : null);
1041
+ || (shouldUseCwdFallback && launchCwd ? findLatestJsonl(launchCwd, launchTs) : null);
956
1042
  if (resolved) {
957
1043
  jsonlPath = resolved;
958
1044
  dlog(`Lazy-resolved JSONL: ${jsonlPath}`);
@@ -960,6 +1046,7 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
960
1046
  else {
961
1047
  // Still no JSONL — render the header/footer so the dashboard isn't blank
962
1048
  renderHeader();
1049
+ renderPreTurnState();
963
1050
  try {
964
1051
  screen.render();
965
1052
  }
@@ -994,6 +1081,9 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
994
1081
  lastFileSize = stat.size;
995
1082
  data = parseJsonlFile(jsonlPath, sessionName);
996
1083
  lastData = data;
1084
+ if (!sessionStartMs && data.startedAt) {
1085
+ sessionStartMs = new Date(data.startedAt).getTime();
1086
+ }
997
1087
  }
998
1088
  catch (err) {
999
1089
  dlog(`Parse error: ${err.message}`);
@@ -1018,19 +1108,24 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1018
1108
  const barWidth = Math.max(10, contextInnerWidth - 4 - CLAWD_W);
1019
1109
  const filled = Math.round((ctxPct / 100) * barWidth);
1020
1110
  const bar = `{${ctxColor}-fg}${'█'.repeat(filled)}{/${ctxColor}-fg}${'░'.repeat(barWidth - filled)}`;
1021
- // Cost breakdown (use actual model pricing, not hardcoded)
1022
- const p = getModelPricing(data.model);
1023
- const rd = (data.totalCacheRead / 1000000) * p.cacheRead;
1024
- const wr = (data.totalCacheCreate / 1000000) * p.cacheWrite;
1025
- const ou = (data.totalOutput / 1000000) * p.output;
1111
+ // Cost breakdown by actual routed model per turn.
1112
+ const breakdown = data.turns.reduce((acc, t) => {
1113
+ const pricing = getModelPricing(t.routedModel || t.model);
1114
+ acc.input += (t.input / 1000000) * pricing.input;
1115
+ acc.read += (t.cacheRead / 1000000) * pricing.cacheRead;
1116
+ acc.write += (t.cacheCreate / 1000000) * pricing.cacheWrite;
1117
+ acc.output += (t.output / 1000000) * pricing.output;
1118
+ return acc;
1119
+ }, { input: 0, read: 0, write: 0, output: 0 });
1026
1120
  // Cache stats
1027
1121
  const hitColor = data.cacheHitRate >= 80 ? 'green' : data.cacheHitRate >= 50 ? 'yellow' : 'red';
1028
1122
  const cappedMax = Math.min(data.maxContextPct, 100);
1029
1123
  contextBox.setContent(` ${bar}\n` +
1030
1124
  ` {${ctxColor}-fg}${ctxPct.toFixed(0)}%{/${ctxColor}-fg} ${tokensK}K/${maxK}K` +
1031
- ` {green-fg}Read{/green-fg} $${rd.toFixed(2)}` +
1032
- ` {yellow-fg}Write{/yellow-fg} $${wr.toFixed(2)}` +
1033
- ` {cyan-fg}Output{/cyan-fg} $${ou.toFixed(2)}\n` +
1125
+ ` {white-fg}Input{/white-fg} $${breakdown.input.toFixed(2)}` +
1126
+ ` {green-fg}Read{/green-fg} $${breakdown.read.toFixed(2)}` +
1127
+ ` {yellow-fg}Write{/yellow-fg} $${breakdown.write.toFixed(2)}` +
1128
+ ` {cyan-fg}Output{/cyan-fg} $${breakdown.output.toFixed(2)}\n` +
1034
1129
  ` {${hitColor}-fg}${data.cacheHitRate.toFixed(0)}% cache{/${hitColor}-fg}` +
1035
1130
  ` peak:${cappedMax.toFixed(0)}%` +
1036
1131
  ` avg:$${data.avgCostPerTurn.toFixed(2)}/t` +
@@ -1092,7 +1187,7 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1092
1187
  // Always show all 8 columns: Turn, Time, Model, Context, Cache Rd, Cache Wr, Output, Cost
1093
1188
  // Shrink flex columns to fit narrow panes instead of dropping them.
1094
1189
  const colNum = 4;
1095
- const colTime = 8; // "HH:MM:SS" or "H:MM AM"
1190
+ const colTime = 8; // "HH:MM:SS"
1096
1191
  const colM = 7;
1097
1192
  const colCtx = 7;
1098
1193
  const colCost = 8;
@@ -1122,16 +1217,16 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1122
1217
  // Format turn timestamp to short time string
1123
1218
  const fmtTime = (iso) => {
1124
1219
  if (!iso)
1125
- return '--:--';
1220
+ return '--:--:--';
1126
1221
  try {
1127
1222
  const d = new Date(iso);
1128
- const h = d.getHours();
1223
+ const h = d.getHours().toString().padStart(2, '0');
1129
1224
  const m = d.getMinutes().toString().padStart(2, '0');
1130
1225
  const s = d.getSeconds().toString().padStart(2, '0');
1131
1226
  return `${h}:${m}:${s}`;
1132
1227
  }
1133
1228
  catch {
1134
- return '--:--';
1229
+ return '--:--:--';
1135
1230
  }
1136
1231
  };
1137
1232
  header = `{bold}${pad('Turn', colNum)}${div}${pad('Time', colTime)}${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}`;
@@ -1162,13 +1257,20 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1162
1257
  dlog(`Table: ${err.message}`);
1163
1258
  }
1164
1259
  // ── Session Totals (footer) ──
1260
+ renderFooter(data);
1261
+ }
1262
+ /** Render footer bar — called from updateDashboard and the 1s timer tick */
1263
+ function renderFooter(data, skipRender = false) {
1264
+ const d = data || lastData;
1265
+ if (!d)
1266
+ return;
1165
1267
  try {
1166
- const totalTokensM = ((data.totalCacheRead + data.totalCacheCreate + data.totalOutput) / 1000000).toFixed(2);
1167
- const totalSavings = data.turns.reduce((s, t) => s + t.savings, 0);
1268
+ const totalTokensM = ((d.totalInput + d.totalCacheRead + d.totalCacheCreate + d.totalOutput) / 1000000).toFixed(2);
1269
+ const totalSavings = d.turns.reduce((s, t) => s + t.savings, 0);
1168
1270
  // Model routing breakdown (uses routedModel for actual model counts)
1169
- const opusCount = data.turns.filter(t => t.routedModel.includes('opus')).length;
1170
- const sonnetCount = data.turns.filter(t => t.routedModel.includes('sonnet')).length;
1171
- const haikuCount = data.turns.filter(t => t.routedModel.includes('haiku')).length;
1271
+ const opusCount = d.turns.filter(t => t.routedModel.includes('opus')).length;
1272
+ const sonnetCount = d.turns.filter(t => t.routedModel.includes('sonnet')).length;
1273
+ const haikuCount = d.turns.filter(t => t.routedModel.includes('haiku')).length;
1172
1274
  const routingParts = [`{magenta-fg}O{/magenta-fg}:${opusCount}`];
1173
1275
  if (sonnetCount > 0)
1174
1276
  routingParts.push(`{blue-fg}S{/blue-fg}:${sonnetCount}`);
@@ -1177,27 +1279,275 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1177
1279
  const savingsStr = totalSavings > 0
1178
1280
  ? ` {green-fg}saved $${totalSavings.toFixed(2)}{/green-fg}`
1179
1281
  : '';
1282
+ const timerStr = sessionStartMs
1283
+ ? `{cyan-fg}${formatElapsed(sessionStartMs)}{/cyan-fg} `
1284
+ : '';
1180
1285
  footerBox.setLabel(` ${sessionName} `);
1181
- footerBox.setContent(` {green-fg}$${data.totalCost.toFixed(2)}{/green-fg}` +
1286
+ footerBox.setContent(` ${timerStr}` +
1287
+ `{green-fg}$${d.totalCost.toFixed(2)}{/green-fg}` +
1182
1288
  ` ${totalTokensM}M` +
1183
1289
  ` ${routingStr}` +
1184
- ` R[A:${data.replayAppliedCount} SZ:${data.replaySkippedSizeCount} ST:${data.replaySkipStoreCount}]` +
1290
+ ` R[A:${d.replayAppliedCount} SZ:${d.replaySkippedSizeCount} ST:${d.replaySkipStoreCount}]` +
1185
1291
  savingsStr +
1186
1292
  (inTmux
1187
- ? ` {gray-fg}Ctrl+C quit{/gray-fg}`
1188
- : ` {gray-fg}? help q quit r refresh{/gray-fg}`));
1293
+ ? ` {gray-fg}Ctrl+Q quit F7 redraw{/gray-fg}`
1294
+ : ` {gray-fg}? help q quit r refresh F7 redraw{/gray-fg}`));
1189
1295
  }
1190
1296
  catch (err) {
1191
1297
  dlog(`Footer: ${err.message}`);
1192
1298
  }
1299
+ if (!skipRender)
1300
+ try {
1301
+ screen.render();
1302
+ }
1303
+ catch (err) {
1304
+ dlog(`Render: ${err.message}`);
1305
+ }
1306
+ }
1307
+ const USAGE_CACHE_PATH = path.join(state_js_1.EKKOS_DIR, 'dashboard-usage-cache.json');
1308
+ const USAGE_STATE_PATH = path.join(state_js_1.EKKOS_DIR, 'dashboard-usage-state.json');
1309
+ const USAGE_LOCK_PATH = path.join(state_js_1.EKKOS_DIR, 'dashboard-usage.lock');
1310
+ const PROFILE_CACHE_PATH = path.join(state_js_1.EKKOS_DIR, 'dashboard-profile-cache.json');
1311
+ const USAGE_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
1312
+ const USAGE_FALLBACK_BACKOFF_MS = 3 * 60 * 1000;
1313
+ const USAGE_MAX_BACKOFF_MS = 10 * 60 * 1000;
1314
+ const USAGE_ERROR_BACKOFF_MS = 60 * 1000;
1315
+ const USAGE_LOCK_STALE_MS = 30 * 1000;
1316
+ const PROFILE_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
1317
+ const PROFILE_REFRESH_MS = 5 * 60 * 1000;
1318
+ function resolveOauthApiBases() {
1319
+ // Match Claude Code behavior: base URL may be customized via env/proxy.
1320
+ const rawBase = (process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com').trim();
1321
+ const base = rawBase.replace(/\/+$/, '');
1322
+ const normalizedBase = base.endsWith('/v1') ? base.slice(0, -3) : base;
1323
+ const bases = [];
1324
+ // ekkOS proxy path doesn't expose OAuth utility endpoints.
1325
+ if (!normalizedBase.includes('/proxy/')) {
1326
+ bases.push(normalizedBase);
1327
+ }
1328
+ bases.push('https://api.anthropic.com');
1329
+ return Array.from(new Set(bases));
1330
+ }
1331
+ const OAUTH_API_BASES = resolveOauthApiBases();
1332
+ const USAGE_API_URLS = OAUTH_API_BASES.map(base => `${base}/api/oauth/usage`);
1333
+ const PROFILE_API_URLS = OAUTH_API_BASES.map(base => `${base}/api/oauth/profile`);
1334
+ function parseRetryAfterMs(retryAfter) {
1335
+ if (!retryAfter)
1336
+ return 0;
1337
+ const retrySeconds = Number.parseInt(retryAfter, 10);
1338
+ if (Number.isFinite(retrySeconds) && retrySeconds > 0) {
1339
+ return retrySeconds * 1000;
1340
+ }
1341
+ const retryAtMs = Date.parse(retryAfter);
1342
+ if (!Number.isNaN(retryAtMs)) {
1343
+ return Math.max(0, retryAtMs - Date.now());
1344
+ }
1345
+ return 0;
1346
+ }
1347
+ function loadUsageCache(maxAgeMs = USAGE_CACHE_MAX_AGE_MS) {
1193
1348
  try {
1194
- screen.render();
1349
+ if (!fs.existsSync(USAGE_CACHE_PATH))
1350
+ return null;
1351
+ const snapshot = JSON.parse(fs.readFileSync(USAGE_CACHE_PATH, 'utf-8'));
1352
+ if (!snapshot?.usage || typeof snapshot.fetchedAt !== 'number')
1353
+ return null;
1354
+ if ((Date.now() - snapshot.fetchedAt) > maxAgeMs)
1355
+ return null;
1356
+ return snapshot.usage;
1195
1357
  }
1196
- catch (err) {
1197
- dlog(`Render: ${err.message}`);
1358
+ catch {
1359
+ return null;
1360
+ }
1361
+ }
1362
+ function saveUsageCache(usage) {
1363
+ try {
1364
+ fs.mkdirSync(path.dirname(USAGE_CACHE_PATH), { recursive: true });
1365
+ const snapshot = { fetchedAt: Date.now(), usage };
1366
+ fs.writeFileSync(USAGE_CACHE_PATH, JSON.stringify(snapshot));
1367
+ }
1368
+ catch {
1369
+ // Ignore cache write errors
1370
+ }
1371
+ }
1372
+ function loadProfileCache(maxAgeMs = PROFILE_CACHE_MAX_AGE_MS) {
1373
+ try {
1374
+ if (!fs.existsSync(PROFILE_CACHE_PATH))
1375
+ return null;
1376
+ const snapshot = JSON.parse(fs.readFileSync(PROFILE_CACHE_PATH, 'utf-8'));
1377
+ if (!snapshot?.profile || typeof snapshot.fetchedAt !== 'number')
1378
+ return null;
1379
+ if ((Date.now() - snapshot.fetchedAt) > maxAgeMs)
1380
+ return null;
1381
+ return snapshot;
1382
+ }
1383
+ catch {
1384
+ return null;
1385
+ }
1386
+ }
1387
+ function saveProfileCache(profile) {
1388
+ try {
1389
+ fs.mkdirSync(path.dirname(PROFILE_CACHE_PATH), { recursive: true });
1390
+ const snapshot = { fetchedAt: Date.now(), profile };
1391
+ fs.writeFileSync(PROFILE_CACHE_PATH, JSON.stringify(snapshot));
1392
+ }
1393
+ catch {
1394
+ // Ignore cache write errors
1395
+ }
1396
+ }
1397
+ function loadUsageState() {
1398
+ try {
1399
+ if (!fs.existsSync(USAGE_STATE_PATH)) {
1400
+ return { updatedAt: 0, backoffUntil: 0, failureCount: 0, lastStatus: null };
1401
+ }
1402
+ const raw = JSON.parse(fs.readFileSync(USAGE_STATE_PATH, 'utf-8'));
1403
+ return {
1404
+ updatedAt: Number.isFinite(raw.updatedAt) ? Number(raw.updatedAt) : 0,
1405
+ backoffUntil: Number.isFinite(raw.backoffUntil) ? Number(raw.backoffUntil) : 0,
1406
+ failureCount: Number.isFinite(raw.failureCount) ? Math.max(0, Number(raw.failureCount)) : 0,
1407
+ lastStatus: typeof raw.lastStatus === 'number' ? raw.lastStatus : null,
1408
+ };
1409
+ }
1410
+ catch {
1411
+ return { updatedAt: 0, backoffUntil: 0, failureCount: 0, lastStatus: null };
1412
+ }
1413
+ }
1414
+ function saveUsageState(state) {
1415
+ try {
1416
+ fs.mkdirSync(path.dirname(USAGE_STATE_PATH), { recursive: true });
1417
+ fs.writeFileSync(USAGE_STATE_PATH, JSON.stringify(state));
1418
+ }
1419
+ catch {
1420
+ // Ignore state write errors
1421
+ }
1422
+ }
1423
+ function tryAcquireUsageLock() {
1424
+ const now = Date.now();
1425
+ const createLock = () => {
1426
+ try {
1427
+ const fd = fs.openSync(USAGE_LOCK_PATH, 'wx');
1428
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, createdAt: now }));
1429
+ return () => {
1430
+ try {
1431
+ fs.closeSync(fd);
1432
+ }
1433
+ catch { }
1434
+ try {
1435
+ fs.unlinkSync(USAGE_LOCK_PATH);
1436
+ }
1437
+ catch { }
1438
+ };
1439
+ }
1440
+ catch (err) {
1441
+ if (err?.code !== 'EEXIST')
1442
+ dlog(`Usage lock error: ${err?.message ?? err}`);
1443
+ return null;
1444
+ }
1445
+ };
1446
+ let release = createLock();
1447
+ if (release)
1448
+ return release;
1449
+ // Recover from stale lock after crash.
1450
+ try {
1451
+ const st = fs.statSync(USAGE_LOCK_PATH);
1452
+ if ((now - st.mtimeMs) > USAGE_LOCK_STALE_MS) {
1453
+ fs.unlinkSync(USAGE_LOCK_PATH);
1454
+ release = createLock();
1455
+ if (release)
1456
+ return release;
1457
+ }
1458
+ }
1459
+ catch { }
1460
+ return null;
1461
+ }
1462
+ function extractClaudeOauthAccessToken(rawBlob) {
1463
+ const raw = (rawBlob || '').trim();
1464
+ if (!raw)
1465
+ return null;
1466
+ const candidates = new Set([raw]);
1467
+ if (/^[0-9a-fA-F]+$/.test(raw) && raw.length % 2 === 0) {
1468
+ const decoded = Buffer.from(raw, 'hex').toString('utf8');
1469
+ candidates.add(decoded);
1470
+ // Keychain blobs can include control chars around JSON fragments.
1471
+ candidates.add(decoded.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '').trim());
1472
+ }
1473
+ for (const candidate of candidates) {
1474
+ if (!candidate)
1475
+ continue;
1476
+ try {
1477
+ const parsed = JSON.parse(candidate);
1478
+ const token = parsed?.claudeAiOauth?.accessToken;
1479
+ if (typeof token === 'string' && token.length > 0)
1480
+ return token;
1481
+ }
1482
+ catch { }
1483
+ try {
1484
+ const parsed = JSON.parse(`{${candidate}}`);
1485
+ const token = parsed?.claudeAiOauth?.accessToken;
1486
+ if (typeof token === 'string' && token.length > 0)
1487
+ return token;
1488
+ }
1489
+ catch { }
1490
+ const oauthMatch = candidate.match(/"claudeAiOauth"\s*:\s*(\{[\s\S]*?\})/);
1491
+ if (oauthMatch) {
1492
+ try {
1493
+ const oauth = JSON.parse(oauthMatch[1]);
1494
+ const token = oauth?.accessToken;
1495
+ if (typeof token === 'string' && token.length > 0)
1496
+ return token;
1497
+ }
1498
+ catch { }
1499
+ const tokenMatch = oauthMatch[1].match(/"accessToken"\s*:\s*"([^"]+)"/);
1500
+ if (tokenMatch?.[1])
1501
+ return tokenMatch[1];
1502
+ }
1503
+ }
1504
+ return null;
1505
+ }
1506
+ let cachedOauthToken = null;
1507
+ let cachedOauthTokenAt = 0;
1508
+ const OAUTH_TOKEN_CACHE_MS = 5 * 60 * 1000;
1509
+ async function readKeychainToken() {
1510
+ return await new Promise((resolve) => {
1511
+ try {
1512
+ const { execFile } = require('child_process');
1513
+ execFile('security', ['find-generic-password', '-s', 'Claude Code-credentials', '-w'], { encoding: 'utf-8', timeout: 5000 }, (err, stdout) => {
1514
+ if (err)
1515
+ return resolve(null);
1516
+ resolve(extractClaudeOauthAccessToken((stdout || '').trim()));
1517
+ });
1518
+ }
1519
+ catch {
1520
+ resolve(null);
1521
+ }
1522
+ });
1523
+ }
1524
+ async function getClaudeOauthAccessToken() {
1525
+ // Short-lived cache avoids repeated keychain/process calls on every poll.
1526
+ if (cachedOauthToken && (Date.now() - cachedOauthTokenAt) < OAUTH_TOKEN_CACHE_MS) {
1527
+ return cachedOauthToken;
1528
+ }
1529
+ let token = null;
1530
+ // File fallback first (fast, non-blocking from process perspective).
1531
+ try {
1532
+ const credsPath = path.join(os.homedir(), '.claude', '.credentials.json');
1533
+ if (fs.existsSync(credsPath)) {
1534
+ const credsBlob = fs.readFileSync(credsPath, 'utf-8');
1535
+ token = extractClaudeOauthAccessToken(credsBlob);
1536
+ }
1537
+ }
1538
+ catch {
1539
+ // ignore
1540
+ }
1541
+ // macOS keychain fallback if file lookup fails.
1542
+ if (!token && process.platform === 'darwin') {
1543
+ token = await readKeychainToken();
1198
1544
  }
1545
+ if (token) {
1546
+ cachedOauthToken = token;
1547
+ cachedOauthTokenAt = Date.now();
1548
+ }
1549
+ return token;
1199
1550
  }
1200
- // ── Usage window update — calls Anthropic's OAuth usage API ──
1201
1551
  /**
1202
1552
  * Fetch real usage limits from Anthropic's OAuth usage endpoint.
1203
1553
  * Returns { five_hour: { utilization, resets_at }, seven_day: { utilization, resets_at } }
@@ -1205,56 +1555,231 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1205
1555
  */
1206
1556
  async function fetchAnthropicUsage() {
1207
1557
  try {
1208
- let token = null;
1209
- if (process.platform === 'darwin') {
1210
- const { execSync } = require('child_process');
1211
- const credsJson = execSync('security find-generic-password -s "Claude Code-credentials" -w', { encoding: 'utf-8', timeout: 5000 }).trim();
1212
- token = JSON.parse(credsJson)?.claudeAiOauth?.accessToken ?? null;
1213
- }
1214
- else if (process.platform === 'win32') {
1215
- // Windows: Claude Code stores credentials in ~/.claude/.credentials.json
1216
- const credsPath = path.join(os.homedir(), '.claude', '.credentials.json');
1217
- if (fs.existsSync(credsPath)) {
1218
- const creds = JSON.parse(fs.readFileSync(credsPath, 'utf-8'));
1219
- token = creds?.claudeAiOauth?.accessToken ?? null;
1558
+ const token = await getClaudeOauthAccessToken();
1559
+ if (!token)
1560
+ return null;
1561
+ const requestHeaders = {
1562
+ 'Authorization': `Bearer ${token}`,
1563
+ 'anthropic-beta': 'oauth-2025-04-20',
1564
+ 'Content-Type': 'application/json',
1565
+ 'User-Agent': 'ekkos-cli/dashboard',
1566
+ };
1567
+ for (let i = 0; i < USAGE_API_URLS.length; i++) {
1568
+ const usageUrl = USAGE_API_URLS[i];
1569
+ const resp = await fetch(usageUrl, { headers: requestHeaders });
1570
+ if (resp.ok) {
1571
+ usageBackoffUntil = 0; // Reset backoff on success
1572
+ usageFailureCount = 0;
1573
+ saveUsageState({
1574
+ updatedAt: Date.now(),
1575
+ backoffUntil: 0,
1576
+ failureCount: 0,
1577
+ lastStatus: resp.status,
1578
+ });
1579
+ return await resp.json();
1580
+ }
1581
+ const canFallbackToNextBase = i < (USAGE_API_URLS.length - 1)
1582
+ && (resp.status === 404 || resp.status === 405);
1583
+ if (canFallbackToNextBase) {
1584
+ dlog(`Usage API ${resp.status} via ${usageUrl} — trying direct fallback`);
1585
+ continue;
1586
+ }
1587
+ // Respect retry-after when present, but enforce a minimum backoff on 429.
1588
+ // The endpoint can return retry-after=0 while still being throttled.
1589
+ const retryAfterRaw = resp.headers.get('retry-after');
1590
+ const retryAfterMs = parseRetryAfterMs(retryAfterRaw);
1591
+ if (resp.status === 429) {
1592
+ usageFailureCount = Math.min(16, usageFailureCount + 1);
1220
1593
  }
1594
+ else {
1595
+ usageFailureCount = 0;
1596
+ }
1597
+ const exponentialMs = resp.status === 429
1598
+ ? Math.min(USAGE_MAX_BACKOFF_MS, USAGE_FALLBACK_BACKOFF_MS * Math.pow(2, Math.max(0, usageFailureCount - 1)))
1599
+ : 0;
1600
+ const backoffMs = Math.max(retryAfterMs, exponentialMs);
1601
+ if (backoffMs > 0) {
1602
+ usageBackoffUntil = Date.now() + backoffMs;
1603
+ saveUsageState({
1604
+ updatedAt: Date.now(),
1605
+ backoffUntil: usageBackoffUntil,
1606
+ failureCount: usageFailureCount,
1607
+ lastStatus: resp.status,
1608
+ });
1609
+ }
1610
+ dlog(`Usage API ${resp.status}, retry-after ${retryAfterRaw ?? 'none'}, backoff ${Math.round(backoffMs / 1000)}s`);
1611
+ if (resp.status === 401 || resp.status === 403) {
1612
+ cachedOauthToken = null;
1613
+ cachedOauthTokenAt = 0;
1614
+ }
1615
+ return null;
1221
1616
  }
1617
+ return null;
1618
+ }
1619
+ catch {
1620
+ usageFailureCount = Math.min(16, usageFailureCount + 1);
1621
+ const backoffMs = Math.min(USAGE_MAX_BACKOFF_MS, USAGE_ERROR_BACKOFF_MS * Math.max(1, usageFailureCount));
1622
+ usageBackoffUntil = Date.now() + backoffMs;
1623
+ saveUsageState({
1624
+ updatedAt: Date.now(),
1625
+ backoffUntil: usageBackoffUntil,
1626
+ failureCount: usageFailureCount,
1627
+ lastStatus: null,
1628
+ });
1629
+ return null;
1630
+ }
1631
+ }
1632
+ async function fetchAnthropicProfile() {
1633
+ try {
1634
+ const token = await getClaudeOauthAccessToken();
1222
1635
  if (!token)
1223
1636
  return null;
1224
- const resp = await fetch('https://api.anthropic.com/api/oauth/usage', {
1225
- headers: {
1226
- 'Authorization': `Bearer ${token}`,
1227
- 'anthropic-beta': 'oauth-2025-04-20',
1228
- 'Content-Type': 'application/json',
1229
- 'User-Agent': 'ekkos-cli/dashboard',
1230
- },
1231
- });
1232
- if (!resp.ok)
1637
+ const requestHeaders = {
1638
+ 'Authorization': `Bearer ${token}`,
1639
+ 'anthropic-beta': 'oauth-2025-04-20',
1640
+ 'Content-Type': 'application/json',
1641
+ 'User-Agent': 'ekkos-cli/dashboard',
1642
+ };
1643
+ for (let i = 0; i < PROFILE_API_URLS.length; i++) {
1644
+ const profileUrl = PROFILE_API_URLS[i];
1645
+ const resp = await fetch(profileUrl, { headers: requestHeaders });
1646
+ if (resp.ok) {
1647
+ return await resp.json();
1648
+ }
1649
+ const canFallbackToNextBase = i < (PROFILE_API_URLS.length - 1)
1650
+ && (resp.status === 404 || resp.status === 405);
1651
+ if (canFallbackToNextBase) {
1652
+ dlog(`Profile API ${resp.status} via ${profileUrl} — trying direct fallback`);
1653
+ continue;
1654
+ }
1655
+ if (resp.status === 401 || resp.status === 403) {
1656
+ cachedOauthToken = null;
1657
+ cachedOauthTokenAt = 0;
1658
+ }
1233
1659
  return null;
1234
- return await resp.json();
1660
+ }
1661
+ return null;
1235
1662
  }
1236
1663
  catch {
1237
1664
  return null;
1238
1665
  }
1239
1666
  }
1667
+ // Backoff timestamp — skip fetch until this time
1668
+ let usageBackoffUntil = 0;
1669
+ let usageFailureCount = 0;
1670
+ let usageFetchInFlight = false;
1671
+ let profileFetchInFlight = false;
1672
+ const usageStateAtStartup = loadUsageState();
1673
+ if (usageStateAtStartup.backoffUntil > Date.now()) {
1674
+ usageBackoffUntil = usageStateAtStartup.backoffUntil;
1675
+ }
1676
+ usageFailureCount = Math.max(usageFailureCount, usageStateAtStartup.failureCount);
1240
1677
  // Cache last fetched usage data so the countdown can tick every second
1241
- let cachedUsage = null;
1242
- // Fetch fresh usage data from API (called every 15s)
1678
+ let cachedUsage = loadUsageCache();
1679
+ const profileSnapshotAtStartup = loadProfileCache();
1680
+ let cachedProfile = profileSnapshotAtStartup?.profile ?? null;
1681
+ let lastProfileFetchAt = profileSnapshotAtStartup?.fetchedAt ?? 0;
1682
+ async function fetchAndCacheProfile(force = false) {
1683
+ if (profileFetchInFlight)
1684
+ return;
1685
+ if (!force && (Date.now() - lastProfileFetchAt) < PROFILE_REFRESH_MS)
1686
+ return;
1687
+ profileFetchInFlight = true;
1688
+ try {
1689
+ const profile = await fetchAnthropicProfile();
1690
+ if (profile) {
1691
+ cachedProfile = profile;
1692
+ lastProfileFetchAt = Date.now();
1693
+ saveProfileCache(profile);
1694
+ }
1695
+ }
1696
+ catch (err) {
1697
+ dlog(`Profile fetch: ${err.message}`);
1698
+ }
1699
+ finally {
1700
+ profileFetchInFlight = false;
1701
+ }
1702
+ }
1703
+ // Fetch fresh usage data from API (called on interval)
1243
1704
  async function fetchAndCacheUsage() {
1705
+ if (usageFetchInFlight)
1706
+ return;
1707
+ // Respect shared state across dashboard processes.
1708
+ const sharedState = loadUsageState();
1709
+ if (sharedState.backoffUntil > usageBackoffUntil) {
1710
+ usageBackoffUntil = sharedState.backoffUntil;
1711
+ }
1712
+ if (sharedState.failureCount > usageFailureCount) {
1713
+ usageFailureCount = sharedState.failureCount;
1714
+ }
1715
+ // Respect backoff from retry-after header
1716
+ if (usageBackoffUntil && Date.now() < usageBackoffUntil) {
1717
+ dlog(`Usage fetch skipped — backoff until ${new Date(usageBackoffUntil).toLocaleTimeString()}`);
1718
+ await fetchAndCacheProfile();
1719
+ renderWindowBox();
1720
+ return;
1721
+ }
1722
+ const releaseLock = tryAcquireUsageLock();
1723
+ if (!releaseLock) {
1724
+ dlog('Usage fetch skipped — another dashboard is fetching');
1725
+ await fetchAndCacheProfile();
1726
+ renderWindowBox();
1727
+ return;
1728
+ }
1729
+ usageFetchInFlight = true;
1244
1730
  try {
1245
- cachedUsage = await fetchAnthropicUsage();
1731
+ const fresh = await fetchAnthropicUsage();
1732
+ // Only update cache if we got data — preserve stale data on 429/errors
1733
+ if (fresh) {
1734
+ cachedUsage = fresh;
1735
+ saveUsageCache(fresh);
1736
+ }
1737
+ await fetchAndCacheProfile(!cachedProfile);
1246
1738
  }
1247
1739
  catch (err) {
1248
1740
  dlog(`Window fetch: ${err.message}`);
1249
1741
  }
1742
+ finally {
1743
+ usageFetchInFlight = false;
1744
+ releaseLock();
1745
+ }
1250
1746
  renderWindowBox();
1251
1747
  }
1252
1748
  // Render countdown from cached data (called every 1s)
1253
- function renderWindowBox() {
1749
+ function renderWindowBox(skipRender = false) {
1254
1750
  try {
1255
1751
  const usage = cachedUsage;
1256
1752
  let line1 = ' {gray-fg}No usage data{/gray-fg}';
1257
1753
  let line2 = '';
1754
+ if (!usage && usageBackoffUntil && Date.now() < usageBackoffUntil) {
1755
+ const remainSec = Math.max(0, Math.round((usageBackoffUntil - Date.now()) / 1000));
1756
+ const mins = Math.floor(remainSec / 60);
1757
+ const secs = String(remainSec % 60).padStart(2, '0');
1758
+ line1 = ` {yellow-fg}Usage API rate-limited{/yellow-fg} retry in {cyan-fg}${mins}:${secs}{/cyan-fg}`;
1759
+ }
1760
+ if (!usage && cachedProfile) {
1761
+ const plan = cachedProfile.organization?.organization_type
1762
+ ? String(cachedProfile.organization.organization_type).replace(/_/g, ' ')
1763
+ : (cachedProfile.account?.has_claude_max
1764
+ ? 'claude max'
1765
+ : (cachedProfile.account?.has_claude_pro ? 'claude pro' : 'oauth'));
1766
+ const tier = cachedProfile.organization?.rate_limit_tier ?? 'unknown';
1767
+ const status = cachedProfile.organization?.subscription_status ?? 'unknown';
1768
+ const extraUsage = cachedProfile.organization?.has_extra_usage_enabled
1769
+ ? ' {green-fg}extra usage on{/green-fg}'
1770
+ : '';
1771
+ line2 =
1772
+ ` {bold}Plan:{/bold} ${plan} {bold}Tier:{/bold} ${tier} {bold}Sub:{/bold} ${status}${extraUsage}`;
1773
+ }
1774
+ // Fixed-width column helpers for aligned rendering:
1775
+ // " 5h: 26% used ⏱ 1h16m12s resets Feb 27, 2026, 12:00 AM"
1776
+ // " Week: 91% used ⏱ 0h16m12s resets Thu, Feb 26, 2026, 11:00 PM"
1777
+ // ^^^^^^ ^^^^^^^^^ ^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1778
+ // label pct col countdown reset time
1779
+ const COL_LABEL = 6; // "5h: " or "Week: " — padded to same width
1780
+ const COL_PCT = 9; // "100% used" max
1781
+ const COL_CD = 11; // "10h00m00s " max
1782
+ const rpad = (s, w) => s.length >= w ? s : s + ' '.repeat(w - s.length);
1258
1783
  // ── 5h Window (from Anthropic OAuth API) ──
1259
1784
  if (usage?.five_hour) {
1260
1785
  const pct = usage.five_hour.utilization;
@@ -1266,12 +1791,13 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1266
1791
  const rS = remainSec % 60;
1267
1792
  const pctColor = pct < 50 ? 'green' : pct < 80 ? 'yellow' : 'red';
1268
1793
  const countdown = `${rH}h${rM.toString().padStart(2, '0')}m${rS.toString().padStart(2, '0')}s`;
1794
+ const pctStr = `${pct.toFixed(0)}% used`;
1269
1795
  const resetDate = new Date(resetAt);
1270
1796
  const resetTime = resetDate.toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
1271
- line1 = ` {bold}5h:{/bold}` +
1272
- ` {${pctColor}-fg}${pct.toFixed(0)}% used{/${pctColor}-fg}` +
1273
- ` {${pctColor}-fg}⏱ ${countdown}{/${pctColor}-fg}` +
1274
- ` resets ${resetTime}`;
1797
+ line1 = ` {bold}${rpad('5h:', COL_LABEL)}{/bold}` +
1798
+ `{${pctColor}-fg}${rpad(pctStr, COL_PCT)}{/${pctColor}-fg}` +
1799
+ ` {${pctColor}-fg}⏱ ${rpad(countdown, COL_CD)}{/${pctColor}-fg}` +
1800
+ `resets ${resetTime}`;
1275
1801
  }
1276
1802
  // ── Weekly (from Anthropic OAuth API) ──
1277
1803
  if (usage?.seven_day) {
@@ -1287,11 +1813,12 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1287
1813
  const countdown = rD > 0
1288
1814
  ? `${rD}d${rH}h${rM.toString().padStart(2, '0')}m`
1289
1815
  : `${rH}h${rM.toString().padStart(2, '0')}m${rS.toString().padStart(2, '0')}s`;
1816
+ const pctStr = `${pct.toFixed(0)}% used`;
1290
1817
  const resetTime = resetAt.toLocaleString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
1291
- line2 = ` {bold}Week:{/bold}` +
1292
- ` {${pctColor}-fg}${pct.toFixed(0)}% used{/${pctColor}-fg}` +
1293
- ` {${pctColor}-fg}⏱ ${countdown}{/${pctColor}-fg}` +
1294
- ` resets ${resetTime}`;
1818
+ line2 = ` {bold}${rpad('Week:', COL_LABEL)}{/bold}` +
1819
+ `{${pctColor}-fg}${rpad(pctStr, COL_PCT)}{/${pctColor}-fg}` +
1820
+ ` {${pctColor}-fg}⏱ ${rpad(countdown, COL_CD)}{/${pctColor}-fg}` +
1821
+ `resets ${resetTime}`;
1295
1822
  }
1296
1823
  windowBox.setContent(line1 + (line2 ? '\n' + line2 : ''));
1297
1824
  }
@@ -1299,17 +1826,94 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1299
1826
  dlog(`Window: ${err.message}`);
1300
1827
  windowBox.setContent(` {gray-fg}Usage data unavailable{/gray-fg}`);
1301
1828
  }
1302
- try {
1303
- screen.render();
1304
- }
1305
- catch { }
1829
+ if (!skipRender)
1830
+ try {
1831
+ screen.render();
1832
+ }
1833
+ catch { }
1306
1834
  }
1307
1835
  // Legacy wrapper for backward compat
1308
1836
  async function updateWindowBox() { await fetchAndCacheUsage(); }
1837
+ // Full dashboard redraw used by F7 and manual refresh recovery paths.
1838
+ let hardRedrawInFlight = false;
1839
+ let lastHardRedrawAt = 0;
1840
+ const HARD_REDRAW_DEBOUNCE_MS = 200;
1841
+ async function hardRedraw(source) {
1842
+ const now = Date.now();
1843
+ if (hardRedrawInFlight)
1844
+ return;
1845
+ if (now - lastHardRedrawAt < HARD_REDRAW_DEBOUNCE_MS)
1846
+ return;
1847
+ hardRedrawInFlight = true;
1848
+ lastHardRedrawAt = now;
1849
+ try {
1850
+ dlog(`Hard redraw (${source})`);
1851
+ try {
1852
+ screen.realloc?.();
1853
+ }
1854
+ catch { }
1855
+ try {
1856
+ screen.program?.clear?.();
1857
+ }
1858
+ catch { }
1859
+ lastLayoutW = 0;
1860
+ lastLayoutH = 0;
1861
+ ensureLayoutSynced();
1862
+ // Force fresh parse + repaint even when JSONL size has not changed.
1863
+ lastFileSize = 0;
1864
+ updateDashboard();
1865
+ await updateWindowBox();
1866
+ if (lastChartSeries) {
1867
+ try {
1868
+ tokenChart.setData(lastChartSeries);
1869
+ }
1870
+ catch { }
1871
+ }
1872
+ try {
1873
+ screen.render();
1874
+ }
1875
+ catch { }
1876
+ }
1877
+ catch (err) {
1878
+ dlog(`Hard redraw failed (${source}): ${err.message}`);
1879
+ }
1880
+ finally {
1881
+ hardRedrawInFlight = false;
1882
+ }
1883
+ }
1884
+ // F7 escape-sequence fallback for terminals/tmux states where blessed key names
1885
+ // are not emitted reliably. Supports plain and modifier variants.
1886
+ const F7_ESCAPE_RE = /\x1b\[18(?:;\d+)?~/;
1887
+ let detachRawF7Listener = null;
1888
+ if (ttyInput && typeof ttyInput.on === 'function') {
1889
+ const onRawInput = (chunk) => {
1890
+ const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
1891
+ if (!F7_ESCAPE_RE.test(text))
1892
+ return;
1893
+ void hardRedraw('f7-escape');
1894
+ };
1895
+ ttyInput.on('data', onRawInput);
1896
+ detachRawF7Listener = () => {
1897
+ try {
1898
+ if (typeof ttyInput.off === 'function')
1899
+ ttyInput.off('data', onRawInput);
1900
+ else if (typeof ttyInput.removeListener === 'function')
1901
+ ttyInput.removeListener('data', onRawInput);
1902
+ }
1903
+ catch { }
1904
+ };
1905
+ }
1309
1906
  // ── Handle terminal resize ──
1310
1907
  // Recalculate all widget positions from new screen.height
1311
1908
  screen.on('resize', () => {
1312
1909
  try {
1910
+ // Force blessed to pick up new terminal dimensions before layout check
1911
+ try {
1912
+ screen.realloc?.();
1913
+ }
1914
+ catch { }
1915
+ lastLayoutW = 0;
1916
+ lastLayoutH = 0;
1313
1917
  ensureLayoutSynced();
1314
1918
  if (lastData) {
1315
1919
  updateDashboard();
@@ -1333,29 +1937,31 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1333
1937
  // KEYBOARD SHORTCUTS - Only capture when dashboard pane has focus
1334
1938
  // In tmux split mode, this prevents capturing keys from Claude Code pane
1335
1939
  // ══════════════════════════════════════════════════════════════════════════
1336
- screen.key(['C-c'], () => {
1940
+ function shutdownDashboard() {
1941
+ detachRawF7Listener?.();
1337
1942
  clearInterval(pollInterval);
1338
1943
  clearInterval(windowPollInterval);
1339
- clearInterval(countdownInterval);
1944
+ clearInterval(tickInterval);
1340
1945
  clearInterval(headerAnimInterval);
1341
1946
  clearInterval(fortuneInterval);
1342
1947
  screen.destroy();
1343
1948
  process.exit(0);
1949
+ }
1950
+ screen.key(['C-c', 'C-q'], () => {
1951
+ shutdownDashboard();
1952
+ });
1953
+ // F7 — Hard refresh / redraw (works in both tmux and standalone mode)
1954
+ // Forces a full screen realloc + layout recalculation to fix corruption
1955
+ // from rapid terminal resizing. Session data is preserved.
1956
+ screen.key(['f7', 'F7'], () => {
1957
+ void hardRedraw('f7-key');
1344
1958
  });
1345
1959
  if (!inTmux) {
1346
1960
  screen.key(['q'], () => {
1347
- clearInterval(pollInterval);
1348
- clearInterval(windowPollInterval);
1349
- clearInterval(countdownInterval);
1350
- clearInterval(headerAnimInterval);
1351
- clearInterval(fortuneInterval);
1352
- screen.destroy();
1353
- process.exit(0);
1961
+ shutdownDashboard();
1354
1962
  });
1355
1963
  screen.key(['r'], () => {
1356
- lastFileSize = 0;
1357
- updateDashboard();
1358
- updateWindowBox();
1964
+ void hardRedraw('r-key');
1359
1965
  });
1360
1966
  }
1361
1967
  // ══════════════════════════════════════════════════════════════════════════
@@ -1399,7 +2005,7 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1399
2005
  top: 'center',
1400
2006
  left: 'center',
1401
2007
  width: 50,
1402
- height: 16,
2008
+ height: 17,
1403
2009
  content: ('{bold}Navigation{/bold}\n' +
1404
2010
  ' ↑/k/j/↓ Scroll line\n' +
1405
2011
  ' PgUp/u Scroll page up\n' +
@@ -1409,7 +2015,8 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1409
2015
  '\n' +
1410
2016
  '{bold}Controls{/bold}\n' +
1411
2017
  ' r Refresh now\n' +
1412
- ' q/Ctrl+C Quit\n' +
2018
+ ' F7 Hard redraw (fixes corruption)\n' +
2019
+ ' q/Ctrl+Q Quit\n' +
1413
2020
  '\n' +
1414
2021
  '{gray-fg}Press any key to close{/gray-fg}'),
1415
2022
  tags: true,
@@ -1454,8 +2061,18 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1454
2061
  fortuneText = activeFortunes[fortuneIdx];
1455
2062
  renderHeader();
1456
2063
  }, 30000);
1457
- const windowPollInterval = setInterval(fetchAndCacheUsage, 15000); // fetch fresh data every 15s
1458
- const countdownInterval = setInterval(renderWindowBox, 1000); // tick countdown every 1s
2064
+ const windowPollInterval = setInterval(fetchAndCacheUsage, 60000); // fetch fresh data every 60s
2065
+ // Single 1s tick for both countdown + session timer (one screen.render instead of three)
2066
+ const tickInterval = setInterval(() => {
2067
+ ensureLayoutSynced();
2068
+ renderWindowBox(true);
2069
+ if (sessionStartMs)
2070
+ renderFooter(null, true);
2071
+ try {
2072
+ screen.render();
2073
+ }
2074
+ catch { }
2075
+ }, 1000);
1459
2076
  }
1460
2077
  // ── Helpers ──
1461
2078
  function fmtK(n) {
@@ -1466,6 +2083,14 @@ function fmtK(n) {
1466
2083
  return String(n);
1467
2084
  }
1468
2085
  function formatK(n) { return fmtK(n); }
2086
+ function formatElapsed(startMs) {
2087
+ const diff = Math.max(0, Math.floor((Date.now() - startMs) / 1000));
2088
+ const h = Math.floor(diff / 3600);
2089
+ const m = Math.floor((diff % 3600) / 60);
2090
+ const s = diff % 60;
2091
+ const pad = (n) => String(n).padStart(2, '0');
2092
+ return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
2093
+ }
1469
2094
  // ── Session picker ──
1470
2095
  async function pickSession() {
1471
2096
  const sessions = await (0, usage_parser_js_1.listEkkosSessions)(20);
@@ -1523,9 +2148,8 @@ exports.dashboardCommand = new commander_1.Command('dashboard')
1523
2148
  }
1524
2149
  const jsonlPath = resolveJsonlPath(sessionName);
1525
2150
  if (!jsonlPath) {
1526
- console.log(chalk_1.default.red(`No JSONL found for "${sessionName}"`));
1527
- console.log(chalk_1.default.gray('Run "ekkos sessions" to see active sessions'));
1528
- process.exit(1);
2151
+ // JSONL may not exist yet (session just started) — launch with lazy resolution
2152
+ console.log(chalk_1.default.gray(`Waiting for JSONL for "${sessionName}"...`));
1529
2153
  }
1530
- await launchDashboard(sessionName, jsonlPath, refreshMs);
2154
+ await launchDashboard(sessionName, jsonlPath || null, refreshMs, null, Date.now());
1531
2155
  });