@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.
- package/dist/capture/jsonl-rewriter.d.ts +1 -1
- package/dist/capture/jsonl-rewriter.js +3 -3
- package/dist/capture/transcript-repair.d.ts +2 -2
- package/dist/capture/transcript-repair.js +2 -2
- package/dist/commands/claw.d.ts +13 -0
- package/dist/commands/claw.js +253 -0
- package/dist/commands/dashboard.js +617 -83
- package/dist/commands/doctor.d.ts +3 -3
- package/dist/commands/doctor.js +6 -79
- package/dist/commands/gemini.d.ts +19 -0
- package/dist/commands/gemini.js +193 -0
- package/dist/commands/init.js +2 -25
- package/dist/commands/run.d.ts +0 -1
- package/dist/commands/run.js +147 -241
- package/dist/commands/scan.d.ts +21 -0
- package/dist/commands/scan.js +386 -0
- package/dist/commands/swarm-dashboard.js +156 -28
- package/dist/commands/swarm.d.ts +1 -1
- package/dist/commands/swarm.js +1 -1
- package/dist/commands/test-claude.d.ts +2 -2
- package/dist/commands/test-claude.js +3 -3
- package/dist/deploy/index.d.ts +0 -2
- package/dist/deploy/index.js +0 -2
- package/dist/deploy/settings.d.ts +2 -2
- package/dist/deploy/settings.js +42 -4
- package/dist/deploy/skills.js +1 -2
- package/dist/index.js +79 -19
- package/dist/lib/usage-parser.js +4 -3
- package/dist/utils/proxy-url.d.ts +12 -1
- package/dist/utils/proxy-url.js +16 -1
- package/dist/utils/templates.js +1 -1
- package/package.json +4 -6
- package/templates/CLAUDE.md +49 -107
- package/dist/agent/daemon.d.ts +0 -130
- package/dist/agent/daemon.js +0 -606
- package/dist/agent/health-check.d.ts +0 -35
- package/dist/agent/health-check.js +0 -243
- package/dist/agent/pty-runner.d.ts +0 -53
- package/dist/agent/pty-runner.js +0 -190
- package/dist/commands/agent.d.ts +0 -50
- package/dist/commands/agent.js +0 -544
- package/dist/commands/setup-remote.d.ts +0 -20
- package/dist/commands/setup-remote.js +0 -582
- package/dist/commands/synk.d.ts +0 -7
- package/dist/commands/synk.js +0 -339
- package/dist/synk/api.d.ts +0 -22
- package/dist/synk/api.js +0 -133
- package/dist/synk/auth.d.ts +0 -7
- package/dist/synk/auth.js +0 -30
- package/dist/synk/config.d.ts +0 -18
- package/dist/synk/config.js +0 -37
- package/dist/synk/daemon/control-client.d.ts +0 -11
- package/dist/synk/daemon/control-client.js +0 -101
- package/dist/synk/daemon/control-server.d.ts +0 -24
- package/dist/synk/daemon/control-server.js +0 -91
- package/dist/synk/daemon/run.d.ts +0 -14
- package/dist/synk/daemon/run.js +0 -338
- package/dist/synk/encryption.d.ts +0 -17
- package/dist/synk/encryption.js +0 -133
- package/dist/synk/index.d.ts +0 -13
- package/dist/synk/index.js +0 -36
- package/dist/synk/machine-client.d.ts +0 -42
- package/dist/synk/machine-client.js +0 -218
- package/dist/synk/persistence.d.ts +0 -51
- package/dist/synk/persistence.js +0 -211
- package/dist/synk/qr.d.ts +0 -5
- package/dist/synk/qr.js +0 -33
- package/dist/synk/session-bridge.d.ts +0 -58
- package/dist/synk/session-bridge.js +0 -171
- package/dist/synk/session-client.d.ts +0 -46
- package/dist/synk/session-client.js +0 -240
- package/dist/synk/types.d.ts +0 -574
- package/dist/synk/types.js +0 -74
- package/dist/utils/verify-remote-terminal.d.ts +0 -10
- package/dist/utils/verify-remote-terminal.js +0 -415
- package/templates/README.md +0 -378
- package/templates/claude-plugins/PHASE2_COMPLETION.md +0 -346
- package/templates/claude-plugins/PLUGIN_PROPOSALS.md +0 -1776
- package/templates/claude-plugins/README.md +0 -587
- package/templates/claude-plugins/agents/code-reviewer.json +0 -14
- package/templates/claude-plugins/agents/debug-detective.json +0 -15
- package/templates/claude-plugins/agents/git-companion.json +0 -14
- package/templates/claude-plugins/blog-manager/.claude-plugin/plugin.json +0 -8
- package/templates/claude-plugins/blog-manager/commands/blog.md +0 -691
- package/templates/claude-plugins/golden-loop-monitor/.claude-plugin/plugin.json +0 -8
- package/templates/claude-plugins/golden-loop-monitor/commands/loop-status.md +0 -434
- package/templates/claude-plugins/learning-tracker/.claude-plugin/plugin.json +0 -8
- package/templates/claude-plugins/learning-tracker/commands/my-patterns.md +0 -282
- package/templates/claude-plugins/memory-lens/.claude-plugin/plugin.json +0 -8
- package/templates/claude-plugins/memory-lens/commands/memory-search.md +0 -181
- package/templates/claude-plugins/pattern-coach/.claude-plugin/plugin.json +0 -8
- package/templates/claude-plugins/pattern-coach/commands/forge.md +0 -365
- package/templates/claude-plugins/project-schema-validator/.claude-plugin/plugin.json +0 -8
- package/templates/claude-plugins/project-schema-validator/commands/validate-schema.md +0 -582
- package/templates/commands/continue.md +0 -47
- package/templates/cursor-rules/ekkos-memory.md +0 -127
- package/templates/ekkos-manifest.json +0 -223
- package/templates/helpers/json-parse.cjs +0 -101
- package/templates/plan-template.md +0 -306
- package/templates/shared/hooks-enabled.json +0 -22
- package/templates/shared/session-words.json +0 -45
- package/templates/skills/ekkOS_Deep_Recall/Skill.md +0 -282
- package/templates/skills/ekkOS_Learn/Skill.md +0 -265
- package/templates/skills/ekkOS_Memory_First/Skill.md +0 -206
- package/templates/skills/ekkOS_Plan_Assist/Skill.md +0 -302
- package/templates/skills/ekkOS_Preferences/Skill.md +0 -247
- package/templates/skills/ekkOS_Reflect/Skill.md +0 -257
- package/templates/skills/ekkOS_Safety/Skill.md +0 -265
- package/templates/skills/ekkOS_Schema/Skill.md +0 -251
- package/templates/skills/ekkOS_Summary/Skill.md +0 -257
- package/templates/spec-template.md +0 -159
- package/templates/windsurf-rules/ekkos-memory.md +0 -127
- package/templates/windsurf-skills/README.md +0 -58
- package/templates/windsurf-skills/ekkos-continue/SKILL.md +0 -81
- package/templates/windsurf-skills/ekkos-golden-loop/SKILL.md +0 -225
- package/templates/windsurf-skills/ekkos-insights/SKILL.md +0 -138
- package/templates/windsurf-skills/ekkos-recall/SKILL.md +0 -96
- package/templates/windsurf-skills/ekkos-safety/SKILL.md +0 -89
- 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:
|
|
75
|
-
'claude-opus-4-5-20250620': { input:
|
|
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:
|
|
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.
|
|
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
|
-
//
|
|
347
|
-
//
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
|
484
|
-
let jsonlPath = s.
|
|
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
|
|
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
|
-
//
|
|
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 (
|
|
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:
|
|
641
|
-
// table:
|
|
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(
|
|
690
|
-
const chartH = Math.max(
|
|
691
|
-
const tableH = Math.max(
|
|
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
|
|
1072
|
-
const
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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
|
-
` {
|
|
1082
|
-
` {
|
|
1083
|
-
` {
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
1271
|
-
if (
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
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
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
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
|
-
|
|
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 =
|
|
1304
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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);
|