mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-05-14 00:23:04 +08:00
feat: add ECC statusline observability hooks
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.
This commit is contained in:
committed by
Affaan Mustafa
parent
e9c8845833
commit
940135ea47
@@ -8,11 +8,9 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const {
|
||||
ensureDir,
|
||||
appendFile,
|
||||
getClaudeDir,
|
||||
} = require('../lib/utils');
|
||||
const { ensureDir, appendFile, getClaudeDir } = require('../lib/utils');
|
||||
const { estimateCost } = require('../lib/cost-estimate');
|
||||
const { sanitizeSessionId } = require('../lib/session-bridge');
|
||||
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
let raw = '';
|
||||
@@ -22,23 +20,6 @@ function toNumber(value) {
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function estimateCost(model, inputTokens, outputTokens) {
|
||||
// Approximate per-1M-token blended rates. Conservative defaults.
|
||||
const table = {
|
||||
'haiku': { in: 0.8, out: 4.0 },
|
||||
'sonnet': { in: 3.0, out: 15.0 },
|
||||
'opus': { in: 15.0, out: 75.0 },
|
||||
};
|
||||
|
||||
const normalized = String(model || '').toLowerCase();
|
||||
let rates = table.sonnet;
|
||||
if (normalized.includes('haiku')) rates = table.haiku;
|
||||
if (normalized.includes('opus')) rates = table.opus;
|
||||
|
||||
const cost = (inputTokens / 1_000_000) * rates.in + (outputTokens / 1_000_000) * rates.out;
|
||||
return Math.round(cost * 1e6) / 1e6;
|
||||
}
|
||||
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (raw.length < MAX_STDIN) {
|
||||
@@ -55,7 +36,11 @@ process.stdin.on('end', () => {
|
||||
const outputTokens = toNumber(usage.output_tokens || usage.completion_tokens || 0);
|
||||
|
||||
const model = String(input.model || input._cursor?.model || process.env.CLAUDE_MODEL || 'unknown');
|
||||
const sessionId = String(process.env.ECC_SESSION_ID || process.env.CLAUDE_SESSION_ID || 'default');
|
||||
const sessionId =
|
||||
sanitizeSessionId(input.session_id) ||
|
||||
sanitizeSessionId(process.env.ECC_SESSION_ID) ||
|
||||
sanitizeSessionId(process.env.CLAUDE_SESSION_ID) ||
|
||||
'default';
|
||||
|
||||
const metricsDir = path.join(getClaudeDir(), 'metrics');
|
||||
ensureDir(metricsDir);
|
||||
@@ -66,7 +51,7 @@ process.stdin.on('end', () => {
|
||||
model,
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
estimated_cost_usd: estimateCost(model, inputTokens, outputTokens),
|
||||
estimated_cost_usd: estimateCost(model, inputTokens, outputTokens)
|
||||
};
|
||||
|
||||
appendFile(path.join(metricsDir, 'costs.jsonl'), `${JSON.stringify(row)}\n`);
|
||||
|
||||
242
scripts/hooks/ecc-context-monitor.js
Normal file
242
scripts/hooks/ecc-context-monitor.js
Normal file
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ECC Context Monitor — PostToolUse hook
|
||||
*
|
||||
* Reads bridge file from ecc-metrics-bridge.js and injects agent-facing
|
||||
* warnings when thresholds are crossed: context exhaustion, high cost,
|
||||
* scope creep, or tool loops.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { sanitizeSessionId, readBridge } = require('../lib/session-bridge');
|
||||
|
||||
const CONTEXT_WARNING_PCT = 35;
|
||||
const CONTEXT_CRITICAL_PCT = 25;
|
||||
const COST_NOTICE_USD = 5;
|
||||
const COST_WARNING_USD = 10;
|
||||
const COST_CRITICAL_USD = 50;
|
||||
const FILES_WARNING_COUNT = 20;
|
||||
const LOOP_THRESHOLD = 3;
|
||||
const STALE_SECONDS = 60;
|
||||
const DEBOUNCE_CALLS = 5;
|
||||
|
||||
/**
|
||||
* Get debounce state file path.
|
||||
* @param {string} sessionId
|
||||
* @returns {string}
|
||||
*/
|
||||
function getWarnPath(sessionId) {
|
||||
return path.join(os.tmpdir(), `ecc-ctx-warn-${sessionId}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read debounce state.
|
||||
* @param {string} sessionId
|
||||
* @returns {object}
|
||||
*/
|
||||
function readWarnState(sessionId) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(getWarnPath(sessionId), 'utf8'));
|
||||
} catch {
|
||||
return { callsSinceWarn: 0, lastSeverity: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write debounce state.
|
||||
* @param {string} sessionId
|
||||
* @param {object} state
|
||||
*/
|
||||
function writeWarnState(sessionId, state) {
|
||||
const target = getWarnPath(sessionId);
|
||||
const tmp = `${target}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(state), 'utf8');
|
||||
fs.renameSync(tmp, target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect tool loops from recent_tools ring buffer.
|
||||
* @param {Array} recentTools
|
||||
* @returns {{detected: boolean, tool: string, count: number}}
|
||||
*/
|
||||
function detectLoop(recentTools) {
|
||||
if (!Array.isArray(recentTools) || recentTools.length < LOOP_THRESHOLD) {
|
||||
return { detected: false, tool: '', count: 0 };
|
||||
}
|
||||
const counts = {};
|
||||
for (const entry of recentTools) {
|
||||
const key = `${entry.tool}:${entry.hash}`;
|
||||
counts[key] = (counts[key] || 0) + 1;
|
||||
}
|
||||
for (const [key, count] of Object.entries(counts)) {
|
||||
if (count >= LOOP_THRESHOLD) {
|
||||
return { detected: true, tool: key.split(':')[0], count };
|
||||
}
|
||||
}
|
||||
return { detected: false, tool: '', count: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate all warning conditions against bridge data.
|
||||
* Returns array of {severity, type, message} sorted by severity desc.
|
||||
*/
|
||||
function evaluateConditions(bridge) {
|
||||
const warnings = [];
|
||||
const remaining = bridge.context_remaining_pct;
|
||||
|
||||
// Context warnings (skip if no context data)
|
||||
if (remaining !== null && remaining !== undefined) {
|
||||
if (remaining <= CONTEXT_CRITICAL_PCT) {
|
||||
warnings.push({
|
||||
severity: 3,
|
||||
type: 'context',
|
||||
message:
|
||||
`CONTEXT CRITICAL: ${remaining}% remaining. Context nearly exhausted. ` +
|
||||
'Inform the user that context is low and ask how they want to proceed. ' +
|
||||
'Do NOT autonomously save state or write handoff files unless the user asks.'
|
||||
});
|
||||
} else if (remaining <= CONTEXT_WARNING_PCT) {
|
||||
warnings.push({
|
||||
severity: 2,
|
||||
type: 'context',
|
||||
message: `CONTEXT WARNING: ${remaining}% remaining. ` + 'Be aware that context is getting limited. Avoid starting new complex work.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Cost warnings
|
||||
const cost = bridge.total_cost_usd || 0;
|
||||
if (cost > COST_CRITICAL_USD) {
|
||||
warnings.push({
|
||||
severity: 3,
|
||||
type: 'cost',
|
||||
message: `COST CRITICAL: Session cost is $${cost.toFixed(2)}. ` + 'Stop and inform the user about high cost before continuing.'
|
||||
});
|
||||
} else if (cost > COST_WARNING_USD) {
|
||||
warnings.push({
|
||||
severity: 2,
|
||||
type: 'cost',
|
||||
message: `COST WARNING: Session cost is $${cost.toFixed(2)}. ` + 'Review whether the current approach justifies the expense.'
|
||||
});
|
||||
} else if (cost > COST_NOTICE_USD) {
|
||||
warnings.push({
|
||||
severity: 1,
|
||||
type: 'cost',
|
||||
message: `COST NOTICE: Session cost is $${cost.toFixed(2)}. ` + 'Consider whether the current approach is efficient.'
|
||||
});
|
||||
}
|
||||
|
||||
// File scope warning
|
||||
const fileCount = bridge.files_modified_count || 0;
|
||||
if (fileCount > FILES_WARNING_COUNT) {
|
||||
warnings.push({
|
||||
severity: 2,
|
||||
type: 'scope',
|
||||
message: `SCOPE WARNING: ${fileCount} files modified this session. ` + 'Consider whether changes are too scattered.'
|
||||
});
|
||||
}
|
||||
|
||||
// Loop detection
|
||||
const loop = detectLoop(bridge.recent_tools);
|
||||
if (loop.detected) {
|
||||
warnings.push({
|
||||
severity: 2,
|
||||
type: 'loop',
|
||||
message: `LOOP WARNING: Tool '${loop.tool}' called ${loop.count} times ` + 'with same parameters in last 5 calls. This may indicate a stuck loop.'
|
||||
});
|
||||
}
|
||||
|
||||
return warnings.sort((a, b) => b.severity - a.severity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map numeric severity to label.
|
||||
*/
|
||||
function severityLabel(n) {
|
||||
if (n >= 3) return 'critical';
|
||||
if (n >= 2) return 'warning';
|
||||
return 'notice';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} rawInput - Raw JSON string from stdin
|
||||
* @returns {string} JSON output with additionalContext or pass-through
|
||||
*/
|
||||
function run(rawInput) {
|
||||
try {
|
||||
const input = rawInput.trim() ? JSON.parse(rawInput) : {};
|
||||
|
||||
const sessionId = sanitizeSessionId(input.session_id) || sanitizeSessionId(process.env.ECC_SESSION_ID) || sanitizeSessionId(process.env.CLAUDE_SESSION_ID);
|
||||
|
||||
if (!sessionId) return rawInput;
|
||||
|
||||
const bridge = readBridge(sessionId);
|
||||
if (!bridge) return rawInput;
|
||||
|
||||
// Stale check for context warnings
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const lastTs = bridge.last_timestamp ? Math.floor(new Date(bridge.last_timestamp).getTime() / 1000) : 0;
|
||||
const isStale = lastTs > 0 && now - lastTs > STALE_SECONDS;
|
||||
|
||||
// If bridge is stale, null out context data (still check cost/scope/loop)
|
||||
const evalBridge = isStale ? { ...bridge, context_remaining_pct: null } : bridge;
|
||||
|
||||
const warnings = evaluateConditions(evalBridge);
|
||||
if (warnings.length === 0) return rawInput;
|
||||
|
||||
// Debounce logic
|
||||
const warnState = readWarnState(sessionId);
|
||||
warnState.callsSinceWarn = (warnState.callsSinceWarn || 0) + 1;
|
||||
|
||||
const topSeverity = severityLabel(warnings[0].severity);
|
||||
const severityEscalated = topSeverity === 'critical' && warnState.lastSeverity !== 'critical';
|
||||
|
||||
const isFirst = !warnState.lastSeverity;
|
||||
if (!isFirst && warnState.callsSinceWarn < DEBOUNCE_CALLS && !severityEscalated) {
|
||||
writeWarnState(sessionId, warnState);
|
||||
return rawInput;
|
||||
}
|
||||
|
||||
// Reset debounce, emit warning
|
||||
warnState.callsSinceWarn = 0;
|
||||
warnState.lastSeverity = topSeverity;
|
||||
writeWarnState(sessionId, warnState);
|
||||
|
||||
// Combine top 2 warnings
|
||||
const message = warnings
|
||||
.slice(0, 2)
|
||||
.map(w => w.message)
|
||||
.join('\n');
|
||||
|
||||
const output = {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PostToolUse',
|
||||
additionalContext: message
|
||||
}
|
||||
};
|
||||
|
||||
return JSON.stringify(output);
|
||||
} catch {
|
||||
// Never block tool execution
|
||||
return rawInput;
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
let data = '';
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (data.length < MAX_STDIN) data += chunk.substring(0, MAX_STDIN - data.length);
|
||||
});
|
||||
process.stdin.on('end', () => {
|
||||
process.stdout.write(run(data));
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { run, evaluateConditions, detectLoop, severityLabel };
|
||||
198
scripts/hooks/ecc-metrics-bridge.js
Normal file
198
scripts/hooks/ecc-metrics-bridge.js
Normal file
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ECC Metrics Bridge — PostToolUse hook
|
||||
*
|
||||
* Maintains a running session aggregate in /tmp/ecc-metrics-{session}.json.
|
||||
* This bridge file is read by ecc-statusline.js and ecc-context-monitor.js,
|
||||
* avoiding the need to scan large JSONL logs on every invocation.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { sanitizeSessionId, readBridge, writeBridgeAtomic } = require('../lib/session-bridge');
|
||||
const { getClaudeDir } = require('../lib/utils');
|
||||
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
const MAX_FILES_TRACKED = 200;
|
||||
const RECENT_TOOLS_SIZE = 5;
|
||||
const HASH_INPUT_LIMIT = 2048;
|
||||
|
||||
function toNumber(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function stableStringify(value, depth = 0) {
|
||||
if (depth > 4) return '[depth-limit]';
|
||||
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(item => stableStringify(item, depth + 1)).join(',')}]`;
|
||||
}
|
||||
return `{${Object.keys(value)
|
||||
.sort()
|
||||
.map(key => `${JSON.stringify(key)}:${stableStringify(value[key], depth + 1)}`)
|
||||
.join(',')}}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash tool call for loop detection.
|
||||
* Uses tool name + a key parameter when available, otherwise a stable input digest.
|
||||
*/
|
||||
function hashToolCall(toolName, toolInput) {
|
||||
const name = String(toolName || '');
|
||||
let key = '';
|
||||
if (name === 'Bash') {
|
||||
key = String(toolInput?.command || '').slice(0, 160);
|
||||
} else if (toolInput?.file_path) {
|
||||
key = String(toolInput.file_path);
|
||||
} else {
|
||||
key = stableStringify(toolInput || {}).slice(0, HASH_INPUT_LIMIT);
|
||||
}
|
||||
return crypto.createHash('sha256').update(`${name}:${key}`).digest('hex').slice(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract modified file paths from tool input.
|
||||
*/
|
||||
function extractFilePaths(toolName, toolInput) {
|
||||
const paths = [];
|
||||
if (!toolInput || typeof toolInput !== 'object') return paths;
|
||||
|
||||
const fp = toolInput.file_path;
|
||||
if (fp && typeof fp === 'string') paths.push(fp);
|
||||
|
||||
const edits = toolInput.edits;
|
||||
if (Array.isArray(edits)) {
|
||||
for (const edit of edits) {
|
||||
if (edit?.file_path && typeof edit.file_path === 'string') {
|
||||
paths.push(edit.file_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read cumulative cost for a session from the tail of costs.jsonl.
|
||||
* Reads last 8KB to avoid scanning entire file.
|
||||
*/
|
||||
function readSessionCost(sessionId) {
|
||||
try {
|
||||
const costsPath = path.join(getClaudeDir(), 'metrics', 'costs.jsonl');
|
||||
const stat = fs.statSync(costsPath);
|
||||
const readSize = Math.min(stat.size, 8192);
|
||||
const fd = fs.openSync(costsPath, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(readSize);
|
||||
fs.readSync(fd, buf, 0, readSize, Math.max(0, stat.size - readSize));
|
||||
const lines = buf.toString('utf8').split('\n').filter(Boolean);
|
||||
|
||||
let totalCost = 0;
|
||||
let totalIn = 0;
|
||||
let totalOut = 0;
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const row = JSON.parse(line);
|
||||
if (row.session_id === sessionId) {
|
||||
totalCost += toNumber(row.estimated_cost_usd);
|
||||
totalIn += toNumber(row.input_tokens);
|
||||
totalOut += toNumber(row.output_tokens);
|
||||
}
|
||||
} catch {
|
||||
/* skip malformed lines */
|
||||
}
|
||||
}
|
||||
return { totalCost, totalIn, totalOut };
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
} catch {
|
||||
return { totalCost: 0, totalIn: 0, totalOut: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} rawInput - Raw JSON string from stdin
|
||||
* @returns {string} Pass-through
|
||||
*/
|
||||
function run(rawInput) {
|
||||
try {
|
||||
const input = rawInput.trim() ? JSON.parse(rawInput) : {};
|
||||
const toolName = String(input.tool_name || '');
|
||||
const toolInput = input.tool_input || {};
|
||||
|
||||
const sessionId = sanitizeSessionId(input.session_id) || sanitizeSessionId(process.env.ECC_SESSION_ID) || sanitizeSessionId(process.env.CLAUDE_SESSION_ID);
|
||||
|
||||
if (!sessionId) return rawInput;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const bridge = readBridge(sessionId) || {
|
||||
session_id: sessionId,
|
||||
total_cost_usd: 0,
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
tool_count: 0,
|
||||
files_modified_count: 0,
|
||||
files_modified: [],
|
||||
recent_tools: [],
|
||||
first_timestamp: now,
|
||||
last_timestamp: now,
|
||||
context_remaining_pct: null
|
||||
};
|
||||
|
||||
// Increment tool count
|
||||
bridge.tool_count = (bridge.tool_count || 0) + 1;
|
||||
bridge.last_timestamp = now;
|
||||
if (!bridge.first_timestamp) bridge.first_timestamp = now;
|
||||
|
||||
// Track modified files (Write/Edit/MultiEdit only)
|
||||
const isWriteOp = /^(Write|Edit|MultiEdit)$/i.test(toolName);
|
||||
if (isWriteOp) {
|
||||
const newPaths = extractFilePaths(toolName, toolInput);
|
||||
const existing = new Set(bridge.files_modified || []);
|
||||
for (const p of newPaths) {
|
||||
if (existing.size < MAX_FILES_TRACKED && !existing.has(p)) {
|
||||
existing.add(p);
|
||||
}
|
||||
}
|
||||
bridge.files_modified = [...existing];
|
||||
bridge.files_modified_count = existing.size;
|
||||
}
|
||||
|
||||
// Ring buffer for loop detection
|
||||
const recent = bridge.recent_tools || [];
|
||||
recent.push({ tool: toolName, hash: hashToolCall(toolName, toolInput) });
|
||||
if (recent.length > RECENT_TOOLS_SIZE) recent.shift();
|
||||
bridge.recent_tools = recent;
|
||||
|
||||
// Update cost from costs.jsonl tail
|
||||
const costs = readSessionCost(sessionId);
|
||||
bridge.total_cost_usd = Math.round(costs.totalCost * 1e6) / 1e6;
|
||||
bridge.total_input_tokens = costs.totalIn;
|
||||
bridge.total_output_tokens = costs.totalOut;
|
||||
|
||||
writeBridgeAtomic(sessionId, bridge);
|
||||
} catch {
|
||||
// Never block tool execution
|
||||
}
|
||||
|
||||
return rawInput;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
let data = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (data.length < MAX_STDIN) data += chunk.substring(0, MAX_STDIN - data.length);
|
||||
});
|
||||
process.stdin.on('end', () => {
|
||||
process.stdout.write(run(data));
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { run, hashToolCall, extractFilePaths, readSessionCost, stableStringify };
|
||||
168
scripts/hooks/ecc-statusline.js
Normal file
168
scripts/hooks/ecc-statusline.js
Normal file
@@ -0,0 +1,168 @@
|
||||
#!/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();
|
||||
Reference in New Issue
Block a user