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