mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-05-13 18:00:35 +08:00
Salvages the useful statusline/context monitor work from stale PR #1504 while preserving the current continuous-learning hook runner wiring. Adds the metrics bridge, context monitor, statusline script, shared cost/session bridge utilities, and tests. Fixes the reviewed false loop-detection hash collision for non-file tools, avoids default-session cost inflation, sanitizes statusline task lookup, and records hook payload session IDs in cost-tracker.
169 lines
5.3 KiB
JavaScript
169 lines
5.3 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* ECC Statusline — statusLine command
|
|
*
|
|
* Displays: model | task | $cost Nt Nf Nm | dir ██░░ N%
|
|
*
|
|
* Registered in settings.json under "statusLine", not in hooks.json.
|
|
* Reads bridge file from ecc-metrics-bridge.js and stdin from Claude Code runtime.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { sanitizeSessionId, readBridge, writeBridgeAtomic } = require('../lib/session-bridge');
|
|
|
|
const AUTO_COMPACT_BUFFER_PCT = 16.5;
|
|
const MAX_STDIN = 1024 * 1024;
|
|
|
|
/**
|
|
* Format duration from ISO timestamp to now.
|
|
* @param {string} isoTimestamp
|
|
* @returns {string} e.g. "5s", "12m", "1h23m"
|
|
*/
|
|
function formatDuration(isoTimestamp) {
|
|
if (!isoTimestamp) return '?';
|
|
const elapsed = Math.floor((Date.now() - new Date(isoTimestamp).getTime()) / 1000);
|
|
if (elapsed < 0) return '?';
|
|
if (elapsed < 60) return `${elapsed}s`;
|
|
const mins = Math.floor(elapsed / 60);
|
|
if (mins < 60) return `${mins}m`;
|
|
const hours = Math.floor(mins / 60);
|
|
const remMins = mins % 60;
|
|
return remMins > 0 ? `${hours}h${remMins}m` : `${hours}h`;
|
|
}
|
|
|
|
/**
|
|
* Build context progress bar with ANSI colors.
|
|
* @param {number} remaining - Raw remaining percentage from Claude Code
|
|
* @returns {string} Colored bar string
|
|
*/
|
|
function buildContextBar(remaining) {
|
|
if (remaining === null || remaining === undefined) return '';
|
|
|
|
const usableRemaining = Math.max(0, ((remaining - AUTO_COMPACT_BUFFER_PCT) / (100 - AUTO_COMPACT_BUFFER_PCT)) * 100);
|
|
const used = Math.max(0, Math.min(100, Math.round(100 - usableRemaining)));
|
|
|
|
const filled = Math.floor(used / 10);
|
|
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(10 - filled);
|
|
|
|
if (used < 50) return ` \x1b[32m${bar} ${used}%\x1b[0m`;
|
|
if (used < 65) return ` \x1b[33m${bar} ${used}%\x1b[0m`;
|
|
if (used < 80) return ` \x1b[38;5;208m${bar} ${used}%\x1b[0m`;
|
|
return ` \x1b[5;31m${bar} ${used}%\x1b[0m`;
|
|
}
|
|
|
|
/**
|
|
* Read current in-progress task from todos directory.
|
|
* @param {string} sessionId
|
|
* @returns {string} Task activeForm text or empty string
|
|
*/
|
|
function readCurrentTask(sessionId) {
|
|
try {
|
|
const safeSessionId = sanitizeSessionId(sessionId);
|
|
if (!safeSessionId) return '';
|
|
|
|
const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
const todosDir = path.join(claudeDir, 'todos');
|
|
if (!fs.existsSync(todosDir)) return '';
|
|
|
|
const files = fs
|
|
.readdirSync(todosDir)
|
|
.filter(f => f.startsWith(safeSessionId) && f.includes('-agent-') && f.endsWith('.json'))
|
|
.map(f => ({ name: f, mtime: fs.statSync(path.join(todosDir, f)).mtime }))
|
|
.sort((a, b) => b.mtime - a.mtime);
|
|
|
|
if (files.length === 0) return '';
|
|
|
|
const todos = JSON.parse(fs.readFileSync(path.join(todosDir, files[0].name), 'utf8'));
|
|
const inProgress = todos.find(t => t.status === 'in_progress');
|
|
return inProgress?.activeForm || '';
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function runStatusline() {
|
|
let input = '';
|
|
const stdinTimeout = setTimeout(() => process.exit(0), 3000);
|
|
process.stdin.setEncoding('utf8');
|
|
process.stdin.on('data', chunk => {
|
|
if (input.length < MAX_STDIN) {
|
|
input += chunk.substring(0, MAX_STDIN - input.length);
|
|
}
|
|
});
|
|
process.stdin.on('end', () => {
|
|
clearTimeout(stdinTimeout);
|
|
try {
|
|
const data = JSON.parse(input);
|
|
const model = data.model?.display_name || 'Claude';
|
|
const dir = data.workspace?.current_dir || process.cwd();
|
|
const session = data.session_id || '';
|
|
const remaining = data.context_window?.remaining_percentage;
|
|
|
|
const sessionId = sanitizeSessionId(session);
|
|
const bridge = sessionId ? readBridge(sessionId) : null;
|
|
|
|
// Write context % back to bridge for context-monitor
|
|
if (sessionId && bridge && remaining !== null && remaining !== undefined) {
|
|
bridge.context_remaining_pct = remaining;
|
|
try {
|
|
writeBridgeAtomic(sessionId, bridge);
|
|
} catch {
|
|
/* best effort */
|
|
}
|
|
}
|
|
|
|
// Current task
|
|
const task = sessionId ? readCurrentTask(sessionId) : '';
|
|
|
|
// Metrics from bridge
|
|
let metricsStr = '';
|
|
if (bridge) {
|
|
const parts = [];
|
|
if (bridge.total_cost_usd > 0) {
|
|
parts.push(`$${bridge.total_cost_usd.toFixed(2)}`);
|
|
}
|
|
if (bridge.tool_count > 0) {
|
|
parts.push(`${bridge.tool_count}t`);
|
|
}
|
|
if (bridge.files_modified_count > 0) {
|
|
parts.push(`${bridge.files_modified_count}f`);
|
|
}
|
|
const dur = formatDuration(bridge.first_timestamp);
|
|
if (dur !== '?') {
|
|
parts.push(dur);
|
|
}
|
|
if (parts.length > 0) {
|
|
metricsStr = `\x1b[36m${parts.join(' ')}\x1b[0m`;
|
|
}
|
|
}
|
|
|
|
// Context bar
|
|
const ctx = buildContextBar(remaining);
|
|
|
|
// Build output
|
|
const dirname = path.basename(dir);
|
|
const segments = [`\x1b[2m${model}\x1b[0m`];
|
|
|
|
if (task) {
|
|
segments.push(`\x1b[1m${task}\x1b[0m`);
|
|
}
|
|
if (metricsStr) {
|
|
segments.push(metricsStr);
|
|
}
|
|
segments.push(`\x1b[2m${dirname}\x1b[0m`);
|
|
|
|
process.stdout.write(segments.join(' \x1b[2m\u2502\x1b[0m ') + ctx);
|
|
} catch {
|
|
// Silent fail
|
|
}
|
|
});
|
|
}
|
|
|
|
module.exports = { formatDuration, buildContextBar, readCurrentTask, MAX_STDIN };
|
|
|
|
if (require.main === module) runStatusline();
|