@ekkos/cli 1.3.2 → 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 (119) 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 +617 -83
  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.js +2 -25
  13. package/dist/commands/run.d.ts +0 -1
  14. package/dist/commands/run.js +147 -241
  15. package/dist/commands/scan.d.ts +21 -0
  16. package/dist/commands/scan.js +386 -0
  17. package/dist/commands/swarm-dashboard.js +156 -28
  18. package/dist/commands/swarm.d.ts +1 -1
  19. package/dist/commands/swarm.js +1 -1
  20. package/dist/commands/test-claude.d.ts +2 -2
  21. package/dist/commands/test-claude.js +3 -3
  22. package/dist/deploy/index.d.ts +0 -2
  23. package/dist/deploy/index.js +0 -2
  24. package/dist/deploy/settings.d.ts +2 -2
  25. package/dist/deploy/settings.js +42 -4
  26. package/dist/deploy/skills.js +1 -2
  27. package/dist/index.js +79 -19
  28. package/dist/lib/usage-parser.js +4 -3
  29. package/dist/utils/proxy-url.d.ts +12 -1
  30. package/dist/utils/proxy-url.js +16 -1
  31. package/dist/utils/templates.js +1 -1
  32. package/package.json +4 -6
  33. package/templates/CLAUDE.md +49 -107
  34. package/dist/agent/daemon.d.ts +0 -130
  35. package/dist/agent/daemon.js +0 -606
  36. package/dist/agent/health-check.d.ts +0 -35
  37. package/dist/agent/health-check.js +0 -243
  38. package/dist/agent/pty-runner.d.ts +0 -53
  39. package/dist/agent/pty-runner.js +0 -190
  40. package/dist/commands/agent.d.ts +0 -50
  41. package/dist/commands/agent.js +0 -544
  42. package/dist/commands/setup-remote.d.ts +0 -20
  43. package/dist/commands/setup-remote.js +0 -582
  44. package/dist/commands/synk.d.ts +0 -7
  45. package/dist/commands/synk.js +0 -339
  46. package/dist/synk/api.d.ts +0 -22
  47. package/dist/synk/api.js +0 -133
  48. package/dist/synk/auth.d.ts +0 -7
  49. package/dist/synk/auth.js +0 -30
  50. package/dist/synk/config.d.ts +0 -18
  51. package/dist/synk/config.js +0 -37
  52. package/dist/synk/daemon/control-client.d.ts +0 -11
  53. package/dist/synk/daemon/control-client.js +0 -101
  54. package/dist/synk/daemon/control-server.d.ts +0 -24
  55. package/dist/synk/daemon/control-server.js +0 -91
  56. package/dist/synk/daemon/run.d.ts +0 -14
  57. package/dist/synk/daemon/run.js +0 -338
  58. package/dist/synk/encryption.d.ts +0 -17
  59. package/dist/synk/encryption.js +0 -133
  60. package/dist/synk/index.d.ts +0 -13
  61. package/dist/synk/index.js +0 -36
  62. package/dist/synk/machine-client.d.ts +0 -42
  63. package/dist/synk/machine-client.js +0 -218
  64. package/dist/synk/persistence.d.ts +0 -51
  65. package/dist/synk/persistence.js +0 -211
  66. package/dist/synk/qr.d.ts +0 -5
  67. package/dist/synk/qr.js +0 -33
  68. package/dist/synk/session-bridge.d.ts +0 -58
  69. package/dist/synk/session-bridge.js +0 -171
  70. package/dist/synk/session-client.d.ts +0 -46
  71. package/dist/synk/session-client.js +0 -240
  72. package/dist/synk/types.d.ts +0 -574
  73. package/dist/synk/types.js +0 -74
  74. package/dist/utils/verify-remote-terminal.d.ts +0 -10
  75. package/dist/utils/verify-remote-terminal.js +0 -415
  76. package/templates/README.md +0 -378
  77. package/templates/claude-plugins/PHASE2_COMPLETION.md +0 -346
  78. package/templates/claude-plugins/PLUGIN_PROPOSALS.md +0 -1776
  79. package/templates/claude-plugins/README.md +0 -587
  80. package/templates/claude-plugins/agents/code-reviewer.json +0 -14
  81. package/templates/claude-plugins/agents/debug-detective.json +0 -15
  82. package/templates/claude-plugins/agents/git-companion.json +0 -14
  83. package/templates/claude-plugins/blog-manager/.claude-plugin/plugin.json +0 -8
  84. package/templates/claude-plugins/blog-manager/commands/blog.md +0 -691
  85. package/templates/claude-plugins/golden-loop-monitor/.claude-plugin/plugin.json +0 -8
  86. package/templates/claude-plugins/golden-loop-monitor/commands/loop-status.md +0 -434
  87. package/templates/claude-plugins/learning-tracker/.claude-plugin/plugin.json +0 -8
  88. package/templates/claude-plugins/learning-tracker/commands/my-patterns.md +0 -282
  89. package/templates/claude-plugins/memory-lens/.claude-plugin/plugin.json +0 -8
  90. package/templates/claude-plugins/memory-lens/commands/memory-search.md +0 -181
  91. package/templates/claude-plugins/pattern-coach/.claude-plugin/plugin.json +0 -8
  92. package/templates/claude-plugins/pattern-coach/commands/forge.md +0 -365
  93. package/templates/claude-plugins/project-schema-validator/.claude-plugin/plugin.json +0 -8
  94. package/templates/claude-plugins/project-schema-validator/commands/validate-schema.md +0 -582
  95. package/templates/commands/continue.md +0 -47
  96. package/templates/cursor-rules/ekkos-memory.md +0 -127
  97. package/templates/ekkos-manifest.json +0 -223
  98. package/templates/helpers/json-parse.cjs +0 -101
  99. package/templates/plan-template.md +0 -306
  100. package/templates/shared/hooks-enabled.json +0 -22
  101. package/templates/shared/session-words.json +0 -45
  102. package/templates/skills/ekkOS_Deep_Recall/Skill.md +0 -282
  103. package/templates/skills/ekkOS_Learn/Skill.md +0 -265
  104. package/templates/skills/ekkOS_Memory_First/Skill.md +0 -206
  105. package/templates/skills/ekkOS_Plan_Assist/Skill.md +0 -302
  106. package/templates/skills/ekkOS_Preferences/Skill.md +0 -247
  107. package/templates/skills/ekkOS_Reflect/Skill.md +0 -257
  108. package/templates/skills/ekkOS_Safety/Skill.md +0 -265
  109. package/templates/skills/ekkOS_Schema/Skill.md +0 -251
  110. package/templates/skills/ekkOS_Summary/Skill.md +0 -257
  111. package/templates/spec-template.md +0 -159
  112. package/templates/windsurf-rules/ekkos-memory.md +0 -127
  113. package/templates/windsurf-skills/README.md +0 -58
  114. package/templates/windsurf-skills/ekkos-continue/SKILL.md +0 -81
  115. package/templates/windsurf-skills/ekkos-golden-loop/SKILL.md +0 -225
  116. package/templates/windsurf-skills/ekkos-insights/SKILL.md +0 -138
  117. package/templates/windsurf-skills/ekkos-recall/SKILL.md +0 -96
  118. package/templates/windsurf-skills/ekkos-safety/SKILL.md +0 -89
  119. 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,
@@ -343,8 +362,9 @@ function resolveJsonlPath(sessionName, createdAfterMs) {
343
362
  return jsonlPath;
344
363
  }
345
364
  // 2) Active-session fallback.
346
- // IMPORTANT: never resolve "pending" sessions to "latest file in project" because
347
- // that can cross-bind concurrent sessions to the same dashboard transcript.
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).
348
368
  const activeSessionsPath = path.join(os.homedir(), '.ekkos', 'active-sessions.json');
349
369
  if (fs.existsSync(activeSessionsPath)) {
350
370
  try {
@@ -352,11 +372,14 @@ function resolveJsonlPath(sessionName, createdAfterMs) {
352
372
  const match = sessions.find((s) => s.sessionName === sessionName);
353
373
  if (match?.projectPath) {
354
374
  if (isStableSessionId(match.sessionId)) {
355
- return findJsonlBySessionId(match.projectPath, match.sessionId);
356
- }
357
- if (isPendingSessionId(match.sessionId)) {
358
- return null;
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;
359
380
  }
381
+ // Pending or unknown sessionId — fall through to timestamp-constrained
382
+ // latest-file lookup. The createdAfterMs guard prevents stale cross-binds.
360
383
  return findLatestJsonl(match.projectPath, createdAfterMs);
361
384
  }
362
385
  }
@@ -475,13 +498,11 @@ async function waitForNewSession() {
475
498
  for (const s of sessions) {
476
499
  const startedMs = new Date(s.startedAt).getTime();
477
500
  if (startedMs >= launchTs - 2000) {
478
- if (!isStableSessionId(s.sessionId)) {
479
- // Wait for stable session identity; pending IDs can collide across concurrent runs.
480
- continue;
481
- }
482
501
  candidateName = s.sessionName;
483
- // Try exact by sessionId first, then constrained name resolution.
484
- let jsonlPath = s.projectPath ? findJsonlBySessionId(s.projectPath, s.sessionId) : null;
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;
485
506
  if (!jsonlPath)
486
507
  jsonlPath = resolveJsonlPath(s.sessionName, launchTs);
487
508
  // Return immediately with session name — JSONL may still be null
@@ -603,7 +624,7 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
603
624
  mouse: false, // Disable ALL mouse capture (allows terminal text selection)
604
625
  grabKeys: false, // Don't grab keyboard input from other panes
605
626
  sendFocus: false, // Don't send focus events (breaks paste)
606
- ignoreLocked: ['C-c', 'C-q'], // Capture Ctrl+C and Ctrl+Q for quit
627
+ ignoreLocked: ['C-c', 'C-q', 'f7'], // Capture Ctrl+C, Ctrl+Q for quit, F7 for hard refresh
607
628
  input: ttyInput, // Use /dev/tty for input (isolated from stdout pipe)
608
629
  output: ttyOutput, // Use /dev/tty for output (isolated from stdout pipe)
609
630
  forceUnicode: true, // Better text rendering
@@ -621,24 +642,34 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
621
642
  // Override alternateBuffer to do nothing (tmux pane isolation)
622
643
  screen.program.alternateBuffer = () => { };
623
644
  screen.program.normalBuffer = () => { };
624
- // 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.
625
648
  if (screen.program.setRawMode) {
626
649
  screen.program.setRawMode = (enabled) => {
627
- // Silently ignore raw mode requests
650
+ // Silently ignore blessed's raw mode requests — we manage it ourselves
628
651
  return enabled;
629
652
  };
630
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
+ }
631
661
  }
632
662
  // ── Zero-gap calculated layout ──
633
663
  //
634
664
  // Every widget has exact top + height computed from screen.height.
635
665
  // Fixed heights for header/context/usage/footer, remaining split
636
- // 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.
637
668
  //
638
669
  // header: 3 rows (session stats + animated logo)
639
670
  // context: 5 rows (progress bar + cost breakdown + cache stats)
640
- // chart: 40% of remaining (token usage graph)
641
- // 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)
642
673
  // usage: 3 rows (Anthropic rate limit window)
643
674
  // footer: 3 rows (totals + routing + keybindings)
644
675
  const LOGO_CHARS = ['e', 'k', 'k', 'O', 'S', '_'];
@@ -684,11 +715,20 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
684
715
  const FOOTER_H = 3;
685
716
  const CLAWD_W = 16; // Width reserved for Clawd mascot in context box
686
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
+ }
687
727
  function calcLayout() {
688
- const H = screen.height;
689
- const remaining = Math.max(6, H - FIXED_H);
690
- const chartH = Math.max(8, Math.floor(remaining * 0.40));
691
- 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);
692
732
  return {
693
733
  header: { top: 0, height: HEADER_H },
694
734
  context: { top: HEADER_H, height: CONTEXT_H },
@@ -981,8 +1021,8 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
981
1021
  footerBox.setLabel(` ${sessionName} `);
982
1022
  footerBox.setContent(` ${timerStr}{green-fg}Ready{/green-fg}` +
983
1023
  (inTmux
984
- ? ` {gray-fg}Ctrl+Q quit{/gray-fg}`
985
- : ` {gray-fg}? help q quit r refresh{/gray-fg}`));
1024
+ ? ` {gray-fg}Ctrl+Q quit F7 redraw{/gray-fg}`
1025
+ : ` {gray-fg}? help q quit r refresh F7 redraw{/gray-fg}`));
986
1026
  }
987
1027
  catch (err) {
988
1028
  dlog(`Pre-turn render: ${err.message}`);
@@ -1068,19 +1108,24 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1068
1108
  const barWidth = Math.max(10, contextInnerWidth - 4 - CLAWD_W);
1069
1109
  const filled = Math.round((ctxPct / 100) * barWidth);
1070
1110
  const bar = `{${ctxColor}-fg}${'█'.repeat(filled)}{/${ctxColor}-fg}${'░'.repeat(barWidth - filled)}`;
1071
- // Cost breakdown (use actual model pricing, not hardcoded)
1072
- const p = getModelPricing(data.model);
1073
- const rd = (data.totalCacheRead / 1000000) * p.cacheRead;
1074
- const wr = (data.totalCacheCreate / 1000000) * p.cacheWrite;
1075
- 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 });
1076
1120
  // Cache stats
1077
1121
  const hitColor = data.cacheHitRate >= 80 ? 'green' : data.cacheHitRate >= 50 ? 'yellow' : 'red';
1078
1122
  const cappedMax = Math.min(data.maxContextPct, 100);
1079
1123
  contextBox.setContent(` ${bar}\n` +
1080
1124
  ` {${ctxColor}-fg}${ctxPct.toFixed(0)}%{/${ctxColor}-fg} ${tokensK}K/${maxK}K` +
1081
- ` {green-fg}Read{/green-fg} $${rd.toFixed(2)}` +
1082
- ` {yellow-fg}Write{/yellow-fg} $${wr.toFixed(2)}` +
1083
- ` {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` +
1084
1129
  ` {${hitColor}-fg}${data.cacheHitRate.toFixed(0)}% cache{/${hitColor}-fg}` +
1085
1130
  ` peak:${cappedMax.toFixed(0)}%` +
1086
1131
  ` avg:$${data.avgCostPerTurn.toFixed(2)}/t` +
@@ -1142,7 +1187,7 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1142
1187
  // Always show all 8 columns: Turn, Time, Model, Context, Cache Rd, Cache Wr, Output, Cost
1143
1188
  // Shrink flex columns to fit narrow panes instead of dropping them.
1144
1189
  const colNum = 4;
1145
- const colTime = 8; // "HH:MM:SS" or "H:MM AM"
1190
+ const colTime = 8; // "HH:MM:SS"
1146
1191
  const colM = 7;
1147
1192
  const colCtx = 7;
1148
1193
  const colCost = 8;
@@ -1172,16 +1217,16 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1172
1217
  // Format turn timestamp to short time string
1173
1218
  const fmtTime = (iso) => {
1174
1219
  if (!iso)
1175
- return '--:--';
1220
+ return '--:--:--';
1176
1221
  try {
1177
1222
  const d = new Date(iso);
1178
- const h = d.getHours();
1223
+ const h = d.getHours().toString().padStart(2, '0');
1179
1224
  const m = d.getMinutes().toString().padStart(2, '0');
1180
1225
  const s = d.getSeconds().toString().padStart(2, '0');
1181
1226
  return `${h}:${m}:${s}`;
1182
1227
  }
1183
1228
  catch {
1184
- return '--:--';
1229
+ return '--:--:--';
1185
1230
  }
1186
1231
  };
1187
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}`;
@@ -1220,7 +1265,7 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1220
1265
  if (!d)
1221
1266
  return;
1222
1267
  try {
1223
- const totalTokensM = ((d.totalCacheRead + d.totalCacheCreate + d.totalOutput) / 1000000).toFixed(2);
1268
+ const totalTokensM = ((d.totalInput + d.totalCacheRead + d.totalCacheCreate + d.totalOutput) / 1000000).toFixed(2);
1224
1269
  const totalSavings = d.turns.reduce((s, t) => s + t.savings, 0);
1225
1270
  // Model routing breakdown (uses routedModel for actual model counts)
1226
1271
  const opusCount = d.turns.filter(t => t.routedModel.includes('opus')).length;
@@ -1245,8 +1290,8 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1245
1290
  ` R[A:${d.replayAppliedCount} SZ:${d.replaySkippedSizeCount} ST:${d.replaySkipStoreCount}]` +
1246
1291
  savingsStr +
1247
1292
  (inTmux
1248
- ? ` {gray-fg}Ctrl+Q quit{/gray-fg}`
1249
- : ` {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}`));
1250
1295
  }
1251
1296
  catch (err) {
1252
1297
  dlog(`Footer: ${err.message}`);
@@ -1259,7 +1304,250 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1259
1304
  dlog(`Render: ${err.message}`);
1260
1305
  }
1261
1306
  }
1262
- // ── Usage window update — calls Anthropic's OAuth usage API ──
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) {
1348
+ try {
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;
1357
+ }
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();
1544
+ }
1545
+ if (token) {
1546
+ cachedOauthToken = token;
1547
+ cachedOauthTokenAt = Date.now();
1548
+ }
1549
+ return token;
1550
+ }
1263
1551
  /**
1264
1552
  * Fetch real usage limits from Anthropic's OAuth usage endpoint.
1265
1553
  * Returns { five_hour: { utilization, resets_at }, seven_day: { utilization, resets_at } }
@@ -1267,48 +1555,194 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1267
1555
  */
1268
1556
  async function fetchAnthropicUsage() {
1269
1557
  try {
1270
- let token = null;
1271
- if (process.platform === 'darwin') {
1272
- const { execSync } = require('child_process');
1273
- const credsJson = execSync('security find-generic-password -s "Claude Code-credentials" -w', { encoding: 'utf-8', timeout: 5000 }).trim();
1274
- token = JSON.parse(credsJson)?.claudeAiOauth?.accessToken ?? null;
1275
- }
1276
- else if (process.platform === 'win32') {
1277
- // Windows: Claude Code stores credentials in ~/.claude/.credentials.json
1278
- const credsPath = path.join(os.homedir(), '.claude', '.credentials.json');
1279
- if (fs.existsSync(credsPath)) {
1280
- const creds = JSON.parse(fs.readFileSync(credsPath, 'utf-8'));
1281
- 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);
1593
+ }
1594
+ else {
1595
+ usageFailureCount = 0;
1282
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;
1283
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();
1284
1635
  if (!token)
1285
1636
  return null;
1286
- const resp = await fetch('https://api.anthropic.com/api/oauth/usage', {
1287
- headers: {
1288
- 'Authorization': `Bearer ${token}`,
1289
- 'anthropic-beta': 'oauth-2025-04-20',
1290
- 'Content-Type': 'application/json',
1291
- 'User-Agent': 'ekkos-cli/dashboard',
1292
- },
1293
- });
1294
- 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
+ }
1295
1659
  return null;
1296
- return await resp.json();
1660
+ }
1661
+ return null;
1297
1662
  }
1298
1663
  catch {
1299
1664
  return null;
1300
1665
  }
1301
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);
1302
1677
  // Cache last fetched usage data so the countdown can tick every second
1303
- let cachedUsage = null;
1304
- // 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)
1305
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;
1306
1730
  try {
1307
- 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);
1308
1738
  }
1309
1739
  catch (err) {
1310
1740
  dlog(`Window fetch: ${err.message}`);
1311
1741
  }
1742
+ finally {
1743
+ usageFetchInFlight = false;
1744
+ releaseLock();
1745
+ }
1312
1746
  renderWindowBox();
1313
1747
  }
1314
1748
  // Render countdown from cached data (called every 1s)
@@ -1317,6 +1751,26 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1317
1751
  const usage = cachedUsage;
1318
1752
  let line1 = ' {gray-fg}No usage data{/gray-fg}';
1319
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
+ }
1320
1774
  // Fixed-width column helpers for aligned rendering:
1321
1775
  // " 5h: 26% used ⏱ 1h16m12s resets Feb 27, 2026, 12:00 AM"
1322
1776
  // " Week: 91% used ⏱ 0h16m12s resets Thu, Feb 26, 2026, 11:00 PM"
@@ -1380,10 +1834,86 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1380
1834
  }
1381
1835
  // Legacy wrapper for backward compat
1382
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
+ }
1383
1906
  // ── Handle terminal resize ──
1384
1907
  // Recalculate all widget positions from new screen.height
1385
1908
  screen.on('resize', () => {
1386
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;
1387
1917
  ensureLayoutSynced();
1388
1918
  if (lastData) {
1389
1919
  updateDashboard();
@@ -1407,7 +1937,8 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1407
1937
  // KEYBOARD SHORTCUTS - Only capture when dashboard pane has focus
1408
1938
  // In tmux split mode, this prevents capturing keys from Claude Code pane
1409
1939
  // ══════════════════════════════════════════════════════════════════════════
1410
- screen.key(['C-c', 'C-q'], () => {
1940
+ function shutdownDashboard() {
1941
+ detachRawF7Listener?.();
1411
1942
  clearInterval(pollInterval);
1412
1943
  clearInterval(windowPollInterval);
1413
1944
  clearInterval(tickInterval);
@@ -1415,21 +1946,22 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1415
1946
  clearInterval(fortuneInterval);
1416
1947
  screen.destroy();
1417
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');
1418
1958
  });
1419
1959
  if (!inTmux) {
1420
1960
  screen.key(['q'], () => {
1421
- clearInterval(pollInterval);
1422
- clearInterval(windowPollInterval);
1423
- clearInterval(tickInterval);
1424
- clearInterval(headerAnimInterval);
1425
- clearInterval(fortuneInterval);
1426
- screen.destroy();
1427
- process.exit(0);
1961
+ shutdownDashboard();
1428
1962
  });
1429
1963
  screen.key(['r'], () => {
1430
- lastFileSize = 0;
1431
- updateDashboard();
1432
- updateWindowBox();
1964
+ void hardRedraw('r-key');
1433
1965
  });
1434
1966
  }
1435
1967
  // ══════════════════════════════════════════════════════════════════════════
@@ -1473,7 +2005,7 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1473
2005
  top: 'center',
1474
2006
  left: 'center',
1475
2007
  width: 50,
1476
- height: 16,
2008
+ height: 17,
1477
2009
  content: ('{bold}Navigation{/bold}\n' +
1478
2010
  ' ↑/k/j/↓ Scroll line\n' +
1479
2011
  ' PgUp/u Scroll page up\n' +
@@ -1483,6 +2015,7 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1483
2015
  '\n' +
1484
2016
  '{bold}Controls{/bold}\n' +
1485
2017
  ' r Refresh now\n' +
2018
+ ' F7 Hard redraw (fixes corruption)\n' +
1486
2019
  ' q/Ctrl+Q Quit\n' +
1487
2020
  '\n' +
1488
2021
  '{gray-fg}Press any key to close{/gray-fg}'),
@@ -1528,9 +2061,10 @@ async function launchDashboard(initialSessionName, initialJsonlPath, refreshMs,
1528
2061
  fortuneText = activeFortunes[fortuneIdx];
1529
2062
  renderHeader();
1530
2063
  }, 30000);
1531
- const windowPollInterval = setInterval(fetchAndCacheUsage, 15000); // fetch fresh data every 15s
2064
+ const windowPollInterval = setInterval(fetchAndCacheUsage, 60000); // fetch fresh data every 60s
1532
2065
  // Single 1s tick for both countdown + session timer (one screen.render instead of three)
1533
2066
  const tickInterval = setInterval(() => {
2067
+ ensureLayoutSynced();
1534
2068
  renderWindowBox(true);
1535
2069
  if (sessionStartMs)
1536
2070
  renderFooter(null, true);