Compare commits

..

6 Commits

Author SHA1 Message Date
ulinzeng
9f9467f826 chore: register new hooks in hooks.json and update statusline example
Add post:ecc-metrics-bridge and post:ecc-context-monitor to PostToolUse
hooks. Update examples/statusline.json with ECC-native statusline config.
2026-04-20 15:15:42 +08:00
ulinzeng
cf79534c2c feat: add ecc-context-monitor with multi-dimensional warnings
PostToolUse hook injecting agent-facing warnings for:
- Context exhaustion (35% WARNING, 25% CRITICAL)
- Session cost ($5 NOTICE, $10 WARNING, $50 CRITICAL)
- Scope creep (>20 files modified)
- Tool loops (same tool+params 3x in last 5 calls)

Includes debounce (5 calls between warnings) with severity escalation bypass.
2026-04-20 15:15:35 +08:00
ulinzeng
aec611a98b feat: add ecc-statusline hook with metrics display
statusLine command showing model, current task, session cost, tool count,
files modified, session duration, and context usage bar with color
thresholds (green/yellow/orange/red).
2026-04-20 15:15:27 +08:00
ulinzeng
0f0efd7d7c feat: add ecc-metrics-bridge PostToolUse hook
Maintains a running session aggregate in /tmp/ecc-metrics-{session}.json
with cost, tool count, files modified, and recent tool ring buffer for
loop detection. Bridge file is read by ecc-statusline and ecc-context-monitor.
2026-04-20 15:15:20 +08:00
ulinzeng
b5294fc89a refactor: extract estimateCost from cost-tracker to shared lib
Replace inline estimateCost() in cost-tracker.js with import from
scripts/lib/cost-estimate.js. No behavior change.
2026-04-20 15:15:13 +08:00
ulinzeng
f579dad768 feat: add cost-estimate and session-bridge shared libs
Extract estimateCost() into scripts/lib/cost-estimate.js for reuse
across cost-tracker and ecc-metrics-bridge hooks.

Add scripts/lib/session-bridge.js with atomic bridge file I/O,
session ID sanitization, and path traversal prevention.
2026-04-20 15:15:07 +08:00
14 changed files with 1624 additions and 654 deletions

View File

@@ -1,19 +1,20 @@
{
"statusLine": {
"type": "command",
"command": "input=$(cat); user=$(whoami); cwd=$(echo \"$input\" | jq -r '.workspace.current_dir' | sed \"s|$HOME|~|g\"); model=$(echo \"$input\" | jq -r '.model.display_name'); time=$(date +%H:%M); remaining=$(echo \"$input\" | jq -r '.context_window.remaining_percentage // empty'); transcript=$(echo \"$input\" | jq -r '.transcript_path'); todo_count=$([ -f \"$transcript\" ] && grep -c '\"type\":\"todo\"' \"$transcript\" 2>/dev/null || echo 0); cd \"$(echo \"$input\" | jq -r '.workspace.current_dir')\" 2>/dev/null; branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo ''); status=''; [ -n \"$branch\" ] && { [ -n \"$(git status --porcelain 2>/dev/null)\" ] && status='*'; }; B='\\033[38;2;30;102;245m'; G='\\033[38;2;64;160;43m'; Y='\\033[38;2;223;142;29m'; M='\\033[38;2;136;57;239m'; C='\\033[38;2;23;146;153m'; R='\\033[0m'; T='\\033[38;2;76;79;105m'; printf \"${C}${user}${R}:${B}${cwd}${R}\"; [ -n \"$branch\" ] && printf \" ${G}${branch}${Y}${status}${R}\"; [ -n \"$remaining\" ] && printf \" ${M}ctx:${remaining}%%${R}\"; printf \" ${T}${model}${R} ${Y}${time}${R}\"; [ \"$todo_count\" -gt 0 ] && printf \" ${C}todos:${todo_count}${R}\"; echo",
"description": "Custom status line showing: user:path branch* ctx:% model time todos:N"
"command": "node \"<plugin-root>/scripts/hooks/ecc-statusline.js\"",
"description": "ECC statusline: model | task | $cost tools files duration | dir | context bar"
},
"_comments": {
"setup": "Replace <plugin-root> with your ECC installation path. For plugin installs, use the resolved path from CLAUDE_PLUGIN_ROOT.",
"display": "Shows model name, current task, session cost, tool count, files modified, session duration, directory, and context usage bar with color thresholds.",
"colors": {
"B": "Blue - directory path",
"G": "Green - git branch",
"Y": "Yellow - dirty status, time",
"M": "Magenta - context remaining",
"C": "Cyan - username, todos",
"T": "Gray - model name"
"green": "Context used < 50%",
"yellow": "Context used < 65%",
"orange": "Context used < 80%",
"red_blink": "Context used >= 80%"
},
"output_example": "affoon:~/projects/myapp main* ctx:73% sonnet-4.6 14:30 todos:3",
"output_example": "Opus 4.6 | Fixing auth bug | $1.23 47t 5f 15m | myproject ███████░░░ 68%",
"dependencies": "Reads bridge file from ecc-metrics-bridge.js PostToolUse hook. Both must be installed for full metrics display.",
"usage": "Copy the statusLine object to your ~/.claude/settings.json"
}
}

View File

@@ -219,6 +219,30 @@
],
"description": "Capture tool use results for continuous learning",
"id": "post:observe:continuous-learning"
},
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node -e \"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\\\"ecc\\\"],[\\\"ecc@ecc\\\"],[\\\"marketplace\\\",\\\"ecc\\\"],[\\\"everything-claude-code\\\"],[\\\"everything-claude-code@everything-claude-code\\\"],[\\\"marketplace\\\",\\\"everything-claude-code\\\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\\\"ecc\\\",\\\"everything-claude-code\\\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js post:ecc-metrics-bridge scripts/hooks/ecc-metrics-bridge.js minimal,standard,strict",
"timeout": 10
}
],
"description": "Maintain running session metrics aggregate for statusline and context monitor",
"id": "post:ecc-metrics-bridge"
},
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node -e \"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\\\"ecc\\\"],[\\\"ecc@ecc\\\"],[\\\"marketplace\\\",\\\"ecc\\\"],[\\\"everything-claude-code\\\"],[\\\"everything-claude-code@everything-claude-code\\\"],[\\\"marketplace\\\",\\\"everything-claude-code\\\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\\\"ecc\\\",\\\"everything-claude-code\\\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js post:ecc-context-monitor scripts/hooks/ecc-context-monitor.js standard,strict",
"timeout": 10
}
],
"description": "Inject agent warnings on context exhaustion, high cost, scope creep, or tool loops",
"id": "post:ecc-context-monitor"
}
],
"PostToolUseFailure": [

View File

@@ -8,11 +8,8 @@
'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 MAX_STDIN = 1024 * 1024;
let raw = '';
@@ -22,23 +19,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) {
@@ -66,7 +46,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`);

View File

@@ -0,0 +1,239 @@
#!/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) {
fs.writeFileSync(getWarnPath(sessionId), JSON.stringify(state), 'utf8');
}
/**
* 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) {
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 };

View File

@@ -0,0 +1,185 @@
#!/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 { estimateCost } = require('../lib/cost-estimate');
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;
function toNumber(value) {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
/**
* Hash tool call for loop detection.
* Uses tool name + a key parameter (file_path for Edit/Write, first 80 chars of command for Bash).
*/
function hashToolCall(toolName, toolInput) {
const name = String(toolName || '');
let key = '';
if (name === 'Bash') {
key = String(toolInput?.command || '').slice(0, 80);
} else {
key = String(toolInput?.file_path || '');
}
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 || row.session_id === 'default') {
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 envSessionId = String(process.env.ECC_SESSION_ID || process.env.CLAUDE_SESSION_ID || 'default');
const costs = readSessionCost(envSessionId);
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 };

View File

@@ -0,0 +1,160 @@
#!/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;
/**
* 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) 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 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(sessionId) && 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 => (input += chunk));
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) {
bridge.context_remaining_pct = remaining;
try {
writeBridgeAtomic(sessionId, bridge);
} catch {
/* best effort */
}
}
// Current task
const task = session ? readCurrentTask(session) : '';
// 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 };
if (require.main === module) runStatusline();

View File

@@ -0,0 +1,32 @@
'use strict';
/**
* Shared cost estimation for ECC hooks.
*
* Approximate per-1M-token blended rates (conservative defaults).
*/
const RATE_TABLE = {
haiku: { in: 0.8, out: 4.0 },
sonnet: { in: 3.0, out: 15.0 },
opus: { in: 15.0, out: 75.0 }
};
/**
* Estimate USD cost from token counts.
* @param {string} model - Model name (may contain "haiku", "sonnet", or "opus")
* @param {number} inputTokens
* @param {number} outputTokens
* @returns {number} Estimated cost in USD (rounded to 6 decimal places)
*/
function estimateCost(model, inputTokens, outputTokens) {
const normalized = String(model || '').toLowerCase();
let rates = RATE_TABLE.sonnet;
if (normalized.includes('haiku')) rates = RATE_TABLE.haiku;
if (normalized.includes('opus')) rates = RATE_TABLE.opus;
const cost = (inputTokens / 1_000_000) * rates.in + (outputTokens / 1_000_000) * rates.out;
return Math.round(cost * 1e6) / 1e6;
}
module.exports = { estimateCost, RATE_TABLE };

View File

@@ -0,0 +1,81 @@
'use strict';
/**
* Shared session bridge utilities for ECC hooks.
*
* The bridge file is a small JSON aggregate in /tmp that allows
* statusline, metrics-bridge, and context-monitor to share state
* without scanning large JSONL logs on every invocation.
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const MAX_SESSION_ID_LENGTH = 64;
/**
* Sanitize a session ID for safe use in file paths.
* Rejects path traversal, strips unsafe chars, limits length.
* @param {string} raw
* @returns {string|null} Safe session ID or null if invalid
*/
function sanitizeSessionId(raw) {
if (!raw || typeof raw !== 'string') return null;
if (/[/\\]|\.\./.test(raw)) return null;
const safe = raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, MAX_SESSION_ID_LENGTH);
return safe || null;
}
/**
* Get the bridge file path for a session.
* @param {string} sessionId - Already-sanitized session ID
* @returns {string}
*/
function getBridgePath(sessionId) {
return path.join(os.tmpdir(), `ecc-metrics-${sessionId}.json`);
}
/**
* Read bridge data. Returns null on any error.
* @param {string} sessionId - Already-sanitized session ID
* @returns {object|null}
*/
function readBridge(sessionId) {
try {
const raw = fs.readFileSync(getBridgePath(sessionId), 'utf8');
return JSON.parse(raw);
} catch {
return null;
}
}
/**
* Write bridge data atomically (write .tmp then rename).
* @param {string} sessionId - Already-sanitized session ID
* @param {object} data
*/
function writeBridgeAtomic(sessionId, data) {
const target = getBridgePath(sessionId);
const tmp = `${target}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(data), 'utf8');
fs.renameSync(tmp, target);
}
/**
* Resolve session ID from environment variables.
* @returns {string|null} Sanitized session ID or null
*/
function resolveSessionId() {
const raw = process.env.ECC_SESSION_ID || process.env.CLAUDE_SESSION_ID || '';
return sanitizeSessionId(raw);
}
module.exports = {
sanitizeSessionId,
getBridgePath,
readBridge,
writeBridgeAtomic,
resolveSessionId,
MAX_SESSION_ID_LENGTH
};

View File

@@ -0,0 +1,238 @@
/**
* Tests for scripts/hooks/ecc-context-monitor.js
*
* Run with: node tests/hooks/ecc-context-monitor.test.js
*/
const assert = require('assert');
const { run, evaluateConditions, detectLoop, severityLabel } = require('../../scripts/hooks/ecc-context-monitor');
// Test helper
function test(name, fn) {
try {
fn();
console.log(` \u2713 ${name}`);
return true;
} catch (err) {
console.log(` \u2717 ${name}`);
console.log(` Error: ${err.message}`);
return false;
}
}
function runTests() {
console.log('\n=== Testing ecc-context-monitor.js ===\n');
let passed = 0;
let failed = 0;
// evaluateConditions — context warnings
console.log('evaluateConditions (context):');
if (
test('remaining 20% triggers CRITICAL context warning', () => {
const warnings = evaluateConditions({ context_remaining_pct: 20 });
const ctx = warnings.find(w => w.type === 'context');
assert.ok(ctx, 'Expected a context warning');
assert.strictEqual(ctx.severity, 3);
assert.ok(ctx.message.includes('CRITICAL'), 'Message should contain CRITICAL');
})
)
passed++;
else failed++;
if (
test('remaining 30% triggers WARNING context warning', () => {
const warnings = evaluateConditions({ context_remaining_pct: 30 });
const ctx = warnings.find(w => w.type === 'context');
assert.ok(ctx, 'Expected a context warning');
assert.strictEqual(ctx.severity, 2);
assert.ok(ctx.message.includes('WARNING'), 'Message should contain WARNING');
})
)
passed++;
else failed++;
if (
test('remaining 50% triggers no context warning', () => {
const warnings = evaluateConditions({ context_remaining_pct: 50 });
const ctx = warnings.find(w => w.type === 'context');
assert.strictEqual(ctx, undefined);
})
)
passed++;
else failed++;
// evaluateConditions — cost warnings
console.log('\nevaluateConditions (cost):');
if (
test('cost $55 triggers CRITICAL cost warning', () => {
const warnings = evaluateConditions({ total_cost_usd: 55 });
const cost = warnings.find(w => w.type === 'cost');
assert.ok(cost, 'Expected a cost warning');
assert.strictEqual(cost.severity, 3);
assert.ok(cost.message.includes('CRITICAL'), 'Message should contain CRITICAL');
})
)
passed++;
else failed++;
if (
test('cost $12 triggers WARNING cost warning', () => {
const warnings = evaluateConditions({ total_cost_usd: 12 });
const cost = warnings.find(w => w.type === 'cost');
assert.ok(cost, 'Expected a cost warning');
assert.strictEqual(cost.severity, 2);
assert.ok(cost.message.includes('WARNING'), 'Message should contain WARNING');
})
)
passed++;
else failed++;
if (
test('cost $6 triggers NOTICE cost warning', () => {
const warnings = evaluateConditions({ total_cost_usd: 6 });
const cost = warnings.find(w => w.type === 'cost');
assert.ok(cost, 'Expected a cost warning');
assert.strictEqual(cost.severity, 1);
assert.ok(cost.message.includes('NOTICE'), 'Message should contain NOTICE');
})
)
passed++;
else failed++;
if (
test('cost $2 triggers no cost warning', () => {
const warnings = evaluateConditions({ total_cost_usd: 2 });
const cost = warnings.find(w => w.type === 'cost');
assert.strictEqual(cost, undefined);
})
)
passed++;
else failed++;
// evaluateConditions — scope warnings
console.log('\nevaluateConditions (scope):');
if (
test('25 files triggers scope WARNING', () => {
const warnings = evaluateConditions({ files_modified_count: 25 });
const scope = warnings.find(w => w.type === 'scope');
assert.ok(scope, 'Expected a scope warning');
assert.strictEqual(scope.severity, 2);
assert.ok(scope.message.includes('SCOPE'), 'Message should contain SCOPE');
})
)
passed++;
else failed++;
if (
test('10 files triggers no scope warning', () => {
const warnings = evaluateConditions({ files_modified_count: 10 });
const scope = warnings.find(w => w.type === 'scope');
assert.strictEqual(scope, undefined);
})
)
passed++;
else failed++;
// detectLoop tests
console.log('\ndetectLoop:');
if (
test('3 identical entries returns detected true', () => {
const entries = [
{ tool: 'Bash', hash: 'aabbccdd' },
{ tool: 'Bash', hash: 'aabbccdd' },
{ tool: 'Bash', hash: 'aabbccdd' }
];
const result = detectLoop(entries);
assert.strictEqual(result.detected, true);
assert.strictEqual(result.tool, 'Bash');
assert.ok(result.count >= 3);
})
)
passed++;
else failed++;
if (
test('all different entries returns detected false', () => {
const entries = [
{ tool: 'Bash', hash: '11111111' },
{ tool: 'Edit', hash: '22222222' },
{ tool: 'Write', hash: '33333333' }
];
const result = detectLoop(entries);
assert.strictEqual(result.detected, false);
})
)
passed++;
else failed++;
if (
test('empty array returns detected false', () => {
const result = detectLoop([]);
assert.strictEqual(result.detected, false);
})
)
passed++;
else failed++;
// severityLabel tests
console.log('\nseverityLabel:');
if (
test('severity 3 returns critical', () => {
assert.strictEqual(severityLabel(3), 'critical');
})
)
passed++;
else failed++;
if (
test('severity 2 returns warning', () => {
assert.strictEqual(severityLabel(2), 'warning');
})
)
passed++;
else failed++;
if (
test('severity 1 returns notice', () => {
assert.strictEqual(severityLabel(1), 'notice');
})
)
passed++;
else failed++;
// run tests
console.log('\nrun:');
if (
test('empty input returns input unchanged', () => {
const result = run('');
assert.strictEqual(result, '');
})
)
passed++;
else failed++;
if (
test('input without session_id returns input unchanged', () => {
const input = JSON.stringify({ tool_name: 'Bash' });
const result = run(input);
assert.strictEqual(result, input);
})
)
passed++;
else failed++;
// Summary
console.log(`\nResults: ${passed} passed, ${failed} failed\n`);
return { passed, failed };
}
const { failed } = runTests();
process.exit(failed > 0 ? 1 : 0);

View File

@@ -0,0 +1,166 @@
/**
* Tests for scripts/hooks/ecc-metrics-bridge.js
*
* Run with: node tests/hooks/ecc-metrics-bridge.test.js
*/
const assert = require('assert');
const { run, hashToolCall, extractFilePaths, readSessionCost } = require('../../scripts/hooks/ecc-metrics-bridge');
// Test helper
function test(name, fn) {
try {
fn();
console.log(` \u2713 ${name}`);
return true;
} catch (err) {
console.log(` \u2717 ${name}`);
console.log(` Error: ${err.message}`);
return false;
}
}
function runTests() {
console.log('\n=== Testing ecc-metrics-bridge.js ===\n');
let passed = 0;
let failed = 0;
// hashToolCall tests
console.log('hashToolCall:');
if (
test('returns 8-char hex string', () => {
const hash = hashToolCall('Bash', { command: 'ls' });
assert.strictEqual(hash.length, 8);
assert.ok(/^[0-9a-f]{8}$/.test(hash), `Expected hex, got: ${hash}`);
})
)
passed++;
else failed++;
if (
test('different Bash commands produce different hashes', () => {
const h1 = hashToolCall('Bash', { command: 'ls' });
const h2 = hashToolCall('Bash', { command: 'pwd' });
assert.notStrictEqual(h1, h2);
})
)
passed++;
else failed++;
if (
test('different Edit file_paths produce different hashes', () => {
const h1 = hashToolCall('Edit', { file_path: 'a.js' });
const h2 = hashToolCall('Edit', { file_path: 'b.js' });
assert.notStrictEqual(h1, h2);
})
)
passed++;
else failed++;
if (
test('same inputs produce same hash (deterministic)', () => {
const h1 = hashToolCall('Write', { file_path: 'x.txt' });
const h2 = hashToolCall('Write', { file_path: 'x.txt' });
assert.strictEqual(h1, h2);
})
)
passed++;
else failed++;
// extractFilePaths tests
console.log('\nextractFilePaths:');
if (
test('Edit with file_path returns [file_path]', () => {
const paths = extractFilePaths('Edit', { file_path: 'a.js' });
assert.deepStrictEqual(paths, ['a.js']);
})
)
passed++;
else failed++;
if (
test('MultiEdit with edits array returns all file_paths', () => {
const paths = extractFilePaths('MultiEdit', {
edits: [{ file_path: 'a.js' }, { file_path: 'b.js' }]
});
assert.deepStrictEqual(paths, ['a.js', 'b.js']);
})
)
passed++;
else failed++;
if (
test('Bash with command returns empty array', () => {
const paths = extractFilePaths('Bash', { command: 'ls' });
assert.deepStrictEqual(paths, []);
})
)
passed++;
else failed++;
if (
test('null toolInput returns empty array', () => {
const paths = extractFilePaths('Edit', null);
assert.deepStrictEqual(paths, []);
})
)
passed++;
else failed++;
// readSessionCost tests
console.log('\nreadSessionCost:');
if (
test('nonexistent session returns object with numeric fields', () => {
const result = readSessionCost('nonexistent-session-cost-test-xyz-999');
assert.strictEqual(typeof result.totalCost, 'number');
assert.strictEqual(typeof result.totalIn, 'number');
assert.strictEqual(typeof result.totalOut, 'number');
assert.ok(result.totalCost >= 0, 'totalCost should be non-negative');
})
)
passed++;
else failed++;
// run tests
console.log('\nrun:');
if (
test('empty input returns empty input without crashing', () => {
const result = run('');
assert.strictEqual(result, '');
})
)
passed++;
else failed++;
if (
test('whitespace-only input returns input unchanged', () => {
const result = run(' ');
assert.strictEqual(result, ' ');
})
)
passed++;
else failed++;
if (
test('input without session_id returns input unchanged', () => {
const input = JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'ls' } });
const result = run(input);
assert.strictEqual(result, input);
})
)
passed++;
else failed++;
// Summary
console.log(`\nResults: ${passed} passed, ${failed} failed\n`);
return { passed, failed };
}
const { failed } = runTests();
process.exit(failed > 0 ? 1 : 0);

View File

@@ -0,0 +1,180 @@
/**
* Tests for scripts/hooks/ecc-statusline.js
*
* Run with: node tests/hooks/ecc-statusline.test.js
*/
const assert = require('assert');
const { formatDuration, buildContextBar, readCurrentTask } = require('../../scripts/hooks/ecc-statusline');
// Test helper
function test(name, fn) {
try {
fn();
console.log(` \u2713 ${name}`);
return true;
} catch (err) {
console.log(` \u2717 ${name}`);
console.log(` Error: ${err.message}`);
return false;
}
}
function runTests() {
console.log('\n=== Testing ecc-statusline.js ===\n');
let passed = 0;
let failed = 0;
// formatDuration tests
console.log('formatDuration:');
if (
test('null returns "?"', () => {
assert.strictEqual(formatDuration(null), '?');
})
)
passed++;
else failed++;
if (
test('undefined returns "?"', () => {
assert.strictEqual(formatDuration(undefined), '?');
})
)
passed++;
else failed++;
if (
test('timestamp 30 seconds ago ends with "s"', () => {
const ts = new Date(Date.now() - 30 * 1000).toISOString();
const result = formatDuration(ts);
assert.ok(result.endsWith('s'), `Expected ending in "s", got: ${result}`);
})
)
passed++;
else failed++;
if (
test('timestamp 5 minutes ago ends with "m"', () => {
const ts = new Date(Date.now() - 5 * 60 * 1000).toISOString();
const result = formatDuration(ts);
assert.ok(result.endsWith('m'), `Expected ending in "m", got: ${result}`);
})
)
passed++;
else failed++;
if (
test('timestamp 90 minutes ago contains "h"', () => {
const ts = new Date(Date.now() - 90 * 60 * 1000).toISOString();
const result = formatDuration(ts);
assert.ok(result.includes('h'), `Expected "h" in result, got: ${result}`);
})
)
passed++;
else failed++;
if (
test('future timestamp returns "?"', () => {
const ts = new Date(Date.now() + 60 * 1000).toISOString();
const result = formatDuration(ts);
assert.strictEqual(result, '?');
})
)
passed++;
else failed++;
// buildContextBar tests
console.log('\nbuildContextBar:');
if (
test('null returns empty string', () => {
assert.strictEqual(buildContextBar(null), '');
})
)
passed++;
else failed++;
if (
test('undefined returns empty string', () => {
assert.strictEqual(buildContextBar(undefined), '');
})
)
passed++;
else failed++;
if (
test('80% remaining contains green ANSI code', () => {
const bar = buildContextBar(80);
assert.ok(bar.includes('\x1b[32m'), `Expected green ANSI in: ${JSON.stringify(bar)}`);
})
)
passed++;
else failed++;
if (
test('50% remaining contains yellow ANSI code', () => {
const bar = buildContextBar(50);
assert.ok(bar.includes('\x1b[33m'), `Expected yellow ANSI in: ${JSON.stringify(bar)}`);
})
)
passed++;
else failed++;
if (
test('20% remaining contains red blink ANSI code', () => {
const bar = buildContextBar(20);
assert.ok(bar.includes('\x1b[5;31m'), `Expected red blink ANSI in: ${JSON.stringify(bar)}`);
})
)
passed++;
else failed++;
if (
test('context bar contains block characters', () => {
const bar = buildContextBar(60);
assert.ok(bar.includes('\u2588') || bar.includes('\u2591'), 'Expected block characters in bar');
})
)
passed++;
else failed++;
if (
test('context bar contains percentage', () => {
const bar = buildContextBar(70);
assert.ok(bar.includes('%'), 'Expected percentage in bar');
})
)
passed++;
else failed++;
// readCurrentTask tests
console.log('\nreadCurrentTask:');
if (
test('nonexistent session returns empty string', () => {
const result = readCurrentTask('nonexistent-session-xyz-999');
assert.strictEqual(result, '');
})
)
passed++;
else failed++;
if (
test('empty string session returns empty string', () => {
const result = readCurrentTask('');
assert.strictEqual(result, '');
})
)
passed++;
else failed++;
// Summary
console.log(`\nResults: ${passed} passed, ${failed} failed\n`);
return { passed, failed };
}
const { failed } = runTests();
process.exit(failed > 0 ? 1 : 0);

View File

@@ -0,0 +1,114 @@
/**
* Tests for scripts/lib/cost-estimate.js
*
* Run with: node tests/lib/cost-estimate.test.js
*/
const assert = require('assert');
const { estimateCost, RATE_TABLE } = require('../../scripts/lib/cost-estimate');
// Test helper
function test(name, fn) {
try {
fn();
console.log(` \u2713 ${name}`);
return true;
} catch (err) {
console.log(` \u2717 ${name}`);
console.log(` Error: ${err.message}`);
return false;
}
}
function runTests() {
console.log('\n=== Testing cost-estimate.js ===\n');
let passed = 0;
let failed = 0;
// RATE_TABLE structure
console.log('RATE_TABLE:');
if (
test('RATE_TABLE has haiku, sonnet, opus keys', () => {
assert.ok(RATE_TABLE.haiku, 'Missing haiku');
assert.ok(RATE_TABLE.sonnet, 'Missing sonnet');
assert.ok(RATE_TABLE.opus, 'Missing opus');
assert.strictEqual(typeof RATE_TABLE.haiku.in, 'number');
assert.strictEqual(typeof RATE_TABLE.haiku.out, 'number');
assert.strictEqual(typeof RATE_TABLE.sonnet.in, 'number');
assert.strictEqual(typeof RATE_TABLE.sonnet.out, 'number');
assert.strictEqual(typeof RATE_TABLE.opus.in, 'number');
assert.strictEqual(typeof RATE_TABLE.opus.out, 'number');
})
)
passed++;
else failed++;
// estimateCost tests
console.log('\nestimateCost:');
if (
test('opus 1M/1M tokens returns 90', () => {
const cost = estimateCost('opus', 1_000_000, 1_000_000);
assert.strictEqual(cost, 90);
})
)
passed++;
else failed++;
if (
test('sonnet 1M/1M tokens returns 18', () => {
const cost = estimateCost('sonnet', 1_000_000, 1_000_000);
assert.strictEqual(cost, 18);
})
)
passed++;
else failed++;
if (
test('haiku 1M/1M tokens returns 4.8', () => {
const cost = estimateCost('haiku', 1_000_000, 1_000_000);
assert.strictEqual(cost, 4.8);
})
)
passed++;
else failed++;
if (
test('null model with 0 tokens returns 0', () => {
const cost = estimateCost(null, 0, 0);
assert.strictEqual(cost, 0);
})
)
passed++;
else failed++;
if (
test('full model name claude-opus-4-6 uses opus rates', () => {
const cost = estimateCost('claude-opus-4-6', 500, 200);
// (500 / 1_000_000) * 15 + (200 / 1_000_000) * 75 = 0.0075 + 0.015 = 0.0225
const expected = Math.round(0.0225 * 1e6) / 1e6;
assert.strictEqual(cost, expected);
})
)
passed++;
else failed++;
if (
test('unknown model falls back to sonnet rates', () => {
const cost = estimateCost('unknown-model', 1_000_000, 1_000_000);
assert.strictEqual(cost, 18);
})
)
passed++;
else failed++;
// Summary
console.log(`\nResults: ${passed} passed, ${failed} failed\n`);
return { passed, failed };
}
const { failed } = runTests();
process.exit(failed > 0 ? 1 : 0);

View File

@@ -0,0 +1,174 @@
/**
* Tests for scripts/lib/session-bridge.js
*
* Run with: node tests/lib/session-bridge.test.js
*/
const assert = require('assert');
const fs = require('fs');
const { sanitizeSessionId, getBridgePath, readBridge, writeBridgeAtomic, resolveSessionId, MAX_SESSION_ID_LENGTH } = require('../../scripts/lib/session-bridge');
// Test helper
function test(name, fn) {
try {
fn();
console.log(` \u2713 ${name}`);
return true;
} catch (err) {
console.log(` \u2717 ${name}`);
console.log(` Error: ${err.message}`);
return false;
}
}
function runTests() {
console.log('\n=== Testing session-bridge.js ===\n');
let passed = 0;
let failed = 0;
// sanitizeSessionId tests
console.log('sanitizeSessionId:');
if (
test('valid ID passes through', () => {
assert.strictEqual(sanitizeSessionId('abc-123'), 'abc-123');
})
)
passed++;
else failed++;
if (
test('path traversal returns null', () => {
assert.strictEqual(sanitizeSessionId('../etc/passwd'), null);
})
)
passed++;
else failed++;
if (
test('forward slash returns null', () => {
assert.strictEqual(sanitizeSessionId('/tmp/evil'), null);
})
)
passed++;
else failed++;
if (
test('backslash returns null', () => {
assert.strictEqual(sanitizeSessionId('a\\b'), null);
})
)
passed++;
else failed++;
if (
test('null input returns null', () => {
assert.strictEqual(sanitizeSessionId(null), null);
})
)
passed++;
else failed++;
if (
test('empty string returns null', () => {
assert.strictEqual(sanitizeSessionId(''), null);
})
)
passed++;
else failed++;
if (
test('long string is truncated to MAX_SESSION_ID_LENGTH', () => {
const longId = 'a'.repeat(100);
const result = sanitizeSessionId(longId);
assert.ok(result, 'Should not return null for valid chars');
assert.strictEqual(result.length, MAX_SESSION_ID_LENGTH);
})
)
passed++;
else failed++;
// getBridgePath tests
console.log('\ngetBridgePath:');
if (
test('returns path containing ecc-metrics-', () => {
const p = getBridgePath('test-session');
assert.ok(p.includes('ecc-metrics-'), `Expected ecc-metrics- in path, got: ${p}`);
})
)
passed++;
else failed++;
// writeBridgeAtomic + readBridge roundtrip
console.log('\nwriteBridgeAtomic / readBridge:');
if (
test('roundtrip write then read returns same data', () => {
const testId = `test-bridge-${Date.now()}`;
const data = { session_id: testId, tool_count: 42 };
try {
writeBridgeAtomic(testId, data);
const result = readBridge(testId);
assert.deepStrictEqual(result, data);
} finally {
// Clean up
try {
fs.unlinkSync(getBridgePath(testId));
} catch {
/* ignore */
}
}
})
)
passed++;
else failed++;
if (
test('readBridge with nonexistent session returns null', () => {
const result = readBridge('nonexistent-session-id-999');
assert.strictEqual(result, null);
})
)
passed++;
else failed++;
// resolveSessionId tests
console.log('\nresolveSessionId:');
if (
test('resolveSessionId uses ECC_SESSION_ID env var', () => {
const original = process.env.ECC_SESSION_ID;
try {
process.env.ECC_SESSION_ID = 'env-session-42';
const result = resolveSessionId();
assert.strictEqual(result, 'env-session-42');
} finally {
if (original === undefined) {
delete process.env.ECC_SESSION_ID;
} else {
process.env.ECC_SESSION_ID = original;
}
}
})
)
passed++;
else failed++;
if (
test('MAX_SESSION_ID_LENGTH is 64', () => {
assert.strictEqual(MAX_SESSION_ID_LENGTH, 64);
})
)
passed++;
else failed++;
// Summary
console.log(`\nResults: ${passed} passed, ${failed} failed\n`);
return { passed, failed };
}
const { failed } = runTests();
process.exit(failed > 0 ? 1 : 0);

640
yarn.lock
View File

@@ -100,13 +100,6 @@ __metadata:
languageName: node
linkType: hard
"@gar/promise-retry@npm:^1.0.0":
version: 1.0.3
resolution: "@gar/promise-retry@npm:1.0.3"
checksum: 10c0/885b02c8b0d75b2d215da25f3b639158c4fbe8fefe0d79163304534b9a6d0710db4b7699f7cd3cc1a730792bff04cbe19f4850a62d3e105a663eaeec88f38332
languageName: node
linkType: hard
"@humanfs/core@npm:^0.19.1":
version: 0.19.1
resolution: "@humanfs/core@npm:0.19.1"
@@ -145,15 +138,6 @@ __metadata:
languageName: node
linkType: hard
"@isaacs/fs-minipass@npm:^4.0.0":
version: 4.0.1
resolution: "@isaacs/fs-minipass@npm:4.0.1"
dependencies:
minipass: "npm:^7.0.4"
checksum: 10c0/c25b6dc1598790d5b55c0947a9b7d111cfa92594db5296c3b907e2f533c033666f692a3939eadac17b1c7c40d362d0b0635dc874cbfe3e70db7c2b07cc97a5d2
languageName: node
linkType: hard
"@istanbuljs/schema@npm:^0.1.2, @istanbuljs/schema@npm:^0.1.3":
version: 0.1.3
resolution: "@istanbuljs/schema@npm:0.1.3"
@@ -185,109 +169,30 @@ __metadata:
languageName: node
linkType: hard
"@msgpackr-extract/msgpackr-extract-darwin-arm64@npm:3.0.3":
version: 3.0.3
resolution: "@msgpackr-extract/msgpackr-extract-darwin-arm64@npm:3.0.3"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@msgpackr-extract/msgpackr-extract-darwin-x64@npm:3.0.3":
version: 3.0.3
resolution: "@msgpackr-extract/msgpackr-extract-darwin-x64@npm:3.0.3"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@msgpackr-extract/msgpackr-extract-linux-arm64@npm:3.0.3":
version: 3.0.3
resolution: "@msgpackr-extract/msgpackr-extract-linux-arm64@npm:3.0.3"
conditions: os=linux & cpu=arm64
languageName: node
linkType: hard
"@msgpackr-extract/msgpackr-extract-linux-arm@npm:3.0.3":
version: 3.0.3
resolution: "@msgpackr-extract/msgpackr-extract-linux-arm@npm:3.0.3"
conditions: os=linux & cpu=arm
languageName: node
linkType: hard
"@msgpackr-extract/msgpackr-extract-linux-x64@npm:3.0.3":
version: 3.0.3
resolution: "@msgpackr-extract/msgpackr-extract-linux-x64@npm:3.0.3"
conditions: os=linux & cpu=x64
languageName: node
linkType: hard
"@msgpackr-extract/msgpackr-extract-win32-x64@npm:3.0.3":
version: 3.0.3
resolution: "@msgpackr-extract/msgpackr-extract-win32-x64@npm:3.0.3"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@npmcli/agent@npm:^4.0.0":
version: 4.0.0
resolution: "@npmcli/agent@npm:4.0.0"
dependencies:
agent-base: "npm:^7.1.0"
http-proxy-agent: "npm:^7.0.0"
https-proxy-agent: "npm:^7.0.1"
lru-cache: "npm:^11.2.1"
socks-proxy-agent: "npm:^8.0.3"
checksum: 10c0/f7b5ce0f3dd42c3f8c6546e8433573d8049f67ef11ec22aa4704bc41483122f68bf97752e06302c455ead667af5cb753e6a09bff06632bc465c1cfd4c4b75a53
languageName: node
linkType: hard
"@npmcli/fs@npm:^5.0.0":
version: 5.0.0
resolution: "@npmcli/fs@npm:5.0.0"
dependencies:
semver: "npm:^7.3.5"
checksum: 10c0/26e376d780f60ff16e874a0ac9bc3399186846baae0b6e1352286385ac134d900cc5dafaded77f38d77f86898fc923ae1cee9d7399f0275b1aa24878915d722b
languageName: node
linkType: hard
"@npmcli/redact@npm:^4.0.0":
version: 4.0.0
resolution: "@npmcli/redact@npm:4.0.0"
checksum: 10c0/a1e9ba9c70a6b40e175bda2c3dd8cfdaf096e6b7f7a132c855c083c8dfe545c3237cd56702e2e6627a580b1d63373599d49a1192c4078a85bf47bbde824df31c
languageName: node
linkType: hard
"@opencode-ai/plugin@npm:^1.0.0":
version: 1.14.17
resolution: "@opencode-ai/plugin@npm:1.14.17"
version: 1.3.15
resolution: "@opencode-ai/plugin@npm:1.3.15"
dependencies:
"@opencode-ai/sdk": "npm:1.14.17"
effect: "npm:4.0.0-beta.48"
"@opencode-ai/sdk": "npm:1.3.15"
zod: "npm:4.1.8"
peerDependencies:
"@opentui/core": ">=0.1.100"
"@opentui/solid": ">=0.1.100"
"@opentui/core": ">=0.1.96"
"@opentui/solid": ">=0.1.96"
peerDependenciesMeta:
"@opentui/core":
optional: true
"@opentui/solid":
optional: true
checksum: 10c0/d1962bcc7a4f237050556a5557ce0db3ca0082d95e035ee795fe8623b2d88e536f0f44d48a14ea895407fc3db2faef2c95835b81408d8b9a2dec3115008e4986
checksum: 10c0/1a662ff700812223310612f3c8c7fd4465eda5763d726ec4d29d0eae26babf344ef176c9b987d79fe1e29c8a498178881a47d7080bb9f4db3e70dad59eb8cd9e
languageName: node
linkType: hard
"@opencode-ai/sdk@npm:1.14.17":
version: 1.14.17
resolution: "@opencode-ai/sdk@npm:1.14.17"
"@opencode-ai/sdk@npm:1.3.15":
version: 1.3.15
resolution: "@opencode-ai/sdk@npm:1.3.15"
dependencies:
cross-spawn: "npm:7.0.6"
checksum: 10c0/74985b5967c7655afeb5ae54dc901c53eb123ce53c7af505b0e462d7a7d3e42dc997af40063735a2ad2fb4dcfce1aca2d3c45c5fa5c17a468f411b6b84d0e8b0
languageName: node
linkType: hard
"@standard-schema/spec@npm:^1.1.0":
version: 1.1.0
resolution: "@standard-schema/spec@npm:1.1.0"
checksum: 10c0/d90f55acde4b2deb983529c87e8025fa693de1a5e8b49ecc6eb84d1fd96328add0e03d7d551442156c7432fd78165b2c26ff561b970a9a881f046abb78d6a526
checksum: 10c0/3957ae62e0ec1e339d9493e03a2440c95afdd64a608a2dc9db8383338650318a294280b2142305db5b0147badacbefa0d07e949d31167e5a4a49c9d057d016fa
languageName: node
linkType: hard
@@ -351,13 +256,6 @@ __metadata:
languageName: node
linkType: hard
"abbrev@npm:^4.0.0":
version: 4.0.0
resolution: "abbrev@npm:4.0.0"
checksum: 10c0/b4cc16935235e80702fc90192e349e32f8ef0ed151ef506aa78c81a7c455ec18375c4125414b99f84b2e055199d66383e787675f0bcd87da7a4dbd59f9eac1d5
languageName: node
linkType: hard
"acorn-jsx@npm:^5.3.2":
version: 5.3.2
resolution: "acorn-jsx@npm:5.3.2"
@@ -376,13 +274,6 @@ __metadata:
languageName: node
linkType: hard
"agent-base@npm:^7.1.0, agent-base@npm:^7.1.2":
version: 7.1.4
resolution: "agent-base@npm:7.1.4"
checksum: 10c0/c2c9ab7599692d594b6a161559ada307b7a624fa4c7b03e3afdb5a5e31cd0e53269115b620fcab024c5ac6a6f37fa5eb2e004f076ad30f5f7e6b8b671f7b35fe
languageName: node
linkType: hard
"ajv@npm:^6.12.4":
version: 6.14.0
resolution: "ajv@npm:6.14.0"
@@ -496,24 +387,6 @@ __metadata:
languageName: node
linkType: hard
"cacache@npm:^20.0.1":
version: 20.0.4
resolution: "cacache@npm:20.0.4"
dependencies:
"@npmcli/fs": "npm:^5.0.0"
fs-minipass: "npm:^3.0.0"
glob: "npm:^13.0.0"
lru-cache: "npm:^11.1.0"
minipass: "npm:^7.0.3"
minipass-collect: "npm:^2.0.1"
minipass-flush: "npm:^1.0.5"
minipass-pipeline: "npm:^1.2.4"
p-map: "npm:^7.0.2"
ssri: "npm:^13.0.0"
checksum: 10c0/539bf4020e44ba9ca5afc2ec435623ed7e0dd80c020097677e6b4a0545df5cc9d20b473212d01209c8b4aea43c0d095af0bb6da97bcb991642ea6fac0d7c462b
languageName: node
linkType: hard
"callsites@npm:^3.0.0":
version: 3.1.0
resolution: "callsites@npm:3.1.0"
@@ -552,13 +425,6 @@ __metadata:
languageName: node
linkType: hard
"chownr@npm:^3.0.0":
version: 3.0.0
resolution: "chownr@npm:3.0.0"
checksum: 10c0/43925b87700f7e3893296c8e9c56cc58f926411cce3a6e5898136daaf08f08b9a8eb76d37d3267e707d0dcc17aed2e2ebdf5848c0c3ce95cf910a919935c1b10
languageName: node
linkType: hard
"cliui@npm:^8.0.1":
version: 8.0.1
resolution: "cliui@npm:8.0.1"
@@ -625,7 +491,7 @@ __metadata:
languageName: node
linkType: hard
"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4":
"debug@npm:^4.0.0, debug@npm:^4.3.1, debug@npm:^4.3.2":
version: 4.4.3
resolution: "debug@npm:4.4.3"
dependencies:
@@ -667,13 +533,6 @@ __metadata:
languageName: node
linkType: hard
"detect-libc@npm:^2.0.1":
version: 2.1.2
resolution: "detect-libc@npm:2.1.2"
checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4
languageName: node
linkType: hard
"devlop@npm:^1.0.0":
version: 1.1.0
resolution: "devlop@npm:1.1.0"
@@ -704,24 +563,6 @@ __metadata:
languageName: unknown
linkType: soft
"effect@npm:4.0.0-beta.48":
version: 4.0.0-beta.48
resolution: "effect@npm:4.0.0-beta.48"
dependencies:
"@standard-schema/spec": "npm:^1.1.0"
fast-check: "npm:^4.6.0"
find-my-way-ts: "npm:^0.1.6"
ini: "npm:^6.0.0"
kubernetes-types: "npm:^1.30.0"
msgpackr: "npm:^1.11.9"
multipasta: "npm:^0.2.7"
toml: "npm:^4.1.1"
uuid: "npm:^13.0.0"
yaml: "npm:^2.8.3"
checksum: 10c0/dcd1ac9dbaa43b8bc3b3bd3ec1158b245c038c4522c2d3724d96243dc684773df21118cb2ed6fc648913ac6e9d5f016358d8495515f0c1863d81320b663ce312
languageName: node
linkType: hard
"emoji-regex@npm:^8.0.0":
version: 8.0.0
resolution: "emoji-regex@npm:8.0.0"
@@ -736,13 +577,6 @@ __metadata:
languageName: node
linkType: hard
"env-paths@npm:^2.2.0":
version: 2.2.1
resolution: "env-paths@npm:2.2.1"
checksum: 10c0/285325677bf00e30845e330eec32894f5105529db97496ee3f598478e50f008c5352a41a30e5e72ec9de8a542b5a570b85699cd63bd2bc646dbcb9f311d83bc4
languageName: node
linkType: hard
"escalade@npm:^3.1.1":
version: 3.2.0
resolution: "escalade@npm:3.2.0"
@@ -873,22 +707,6 @@ __metadata:
languageName: node
linkType: hard
"exponential-backoff@npm:^3.1.1":
version: 3.1.3
resolution: "exponential-backoff@npm:3.1.3"
checksum: 10c0/77e3ae682b7b1f4972f563c6dbcd2b0d54ac679e62d5d32f3e5085feba20483cf28bd505543f520e287a56d4d55a28d7874299941faf637e779a1aa5994d1267
languageName: node
linkType: hard
"fast-check@npm:^4.6.0":
version: 4.7.0
resolution: "fast-check@npm:4.7.0"
dependencies:
pure-rand: "npm:^8.0.0"
checksum: 10c0/7edce2b82d11d5325e9e79a2377e1f6e7200d27219edda2e3449d827e994c34461132fc149c90e41b78fc8e6ef4aae77d45350ac7bb1bc4a81110401d0a49fbc
languageName: node
linkType: hard
"fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3":
version: 3.1.3
resolution: "fast-deep-equal@npm:3.1.3"
@@ -938,13 +756,6 @@ __metadata:
languageName: node
linkType: hard
"find-my-way-ts@npm:^0.1.6":
version: 0.1.6
resolution: "find-my-way-ts@npm:0.1.6"
checksum: 10c0/16ad4b15275b56ee0ec361d0c61afbdff4c75bd0ac04112f6910f188cb1058096ba63529c2363914da6bb60266aa4def1025af04af26368ff87eb0df52f2862f
languageName: node
linkType: hard
"find-up@npm:^5.0.0":
version: 5.0.0
resolution: "find-up@npm:5.0.0"
@@ -982,15 +793,6 @@ __metadata:
languageName: node
linkType: hard
"fs-minipass@npm:^3.0.0":
version: 3.0.3
resolution: "fs-minipass@npm:3.0.3"
dependencies:
minipass: "npm:^7.0.3"
checksum: 10c0/63e80da2ff9b621e2cb1596abcb9207f1cf82b968b116ccd7b959e3323144cce7fb141462200971c38bbf2ecca51695069db45265705bed09a7cd93ae5b89f94
languageName: node
linkType: hard
"get-caller-file@npm:^2.0.5":
version: 2.0.5
resolution: "get-caller-file@npm:2.0.5"
@@ -1014,7 +816,7 @@ __metadata:
languageName: node
linkType: hard
"glob@npm:^13.0.0, glob@npm:^13.0.6":
"glob@npm:^13.0.6":
version: 13.0.6
resolution: "glob@npm:13.0.6"
dependencies:
@@ -1033,16 +835,9 @@ __metadata:
linkType: hard
"globals@npm:^17.4.0":
version: 17.5.0
resolution: "globals@npm:17.5.0"
checksum: 10c0/92828102ed2f5637907725f0478038bed02fc83e9fc89300bb753639ba7c022b6c02576fc772117302b431b204591db1f2fa909d26f3f0a9852cc856a941df3f
languageName: node
linkType: hard
"graceful-fs@npm:^4.2.6":
version: 4.2.11
resolution: "graceful-fs@npm:4.2.11"
checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2
version: 17.4.0
resolution: "globals@npm:17.4.0"
checksum: 10c0/2be9e8c2b9035836f13d420b22f0247a328db82967d3bebfc01126d888ed609305f06c05895914e969653af5c6ba35fd7a0920f3e6c869afa60666c810630feb
languageName: node
linkType: hard
@@ -1060,42 +855,6 @@ __metadata:
languageName: node
linkType: hard
"http-cache-semantics@npm:^4.1.1":
version: 4.2.0
resolution: "http-cache-semantics@npm:4.2.0"
checksum: 10c0/45b66a945cf13ec2d1f29432277201313babf4a01d9e52f44b31ca923434083afeca03f18417f599c9ab3d0e7b618ceb21257542338b57c54b710463b4a53e37
languageName: node
linkType: hard
"http-proxy-agent@npm:^7.0.0":
version: 7.0.2
resolution: "http-proxy-agent@npm:7.0.2"
dependencies:
agent-base: "npm:^7.1.0"
debug: "npm:^4.3.4"
checksum: 10c0/4207b06a4580fb85dd6dff521f0abf6db517489e70863dca1a0291daa7f2d3d2d6015a57bd702af068ea5cf9f1f6ff72314f5f5b4228d299c0904135d2aef921
languageName: node
linkType: hard
"https-proxy-agent@npm:^7.0.1":
version: 7.0.6
resolution: "https-proxy-agent@npm:7.0.6"
dependencies:
agent-base: "npm:^7.1.2"
debug: "npm:4"
checksum: 10c0/f729219bc735edb621fa30e6e84e60ee5d00802b8247aac0d7b79b0bd6d4b3294737a337b93b86a0bd9e68099d031858a39260c976dc14cdbba238ba1f8779ac
languageName: node
linkType: hard
"iconv-lite@npm:^0.7.2":
version: 0.7.2
resolution: "iconv-lite@npm:0.7.2"
dependencies:
safer-buffer: "npm:>= 2.1.2 < 3.0.0"
checksum: 10c0/3c228920f3bd307f56bf8363706a776f4a060eb042f131cd23855ceca962951b264d0997ab38a1ad340e1c5df8499ed26e1f4f0db6b2a2ad9befaff22f14b722
languageName: node
linkType: hard
"ignore@npm:^5.2.0":
version: 5.3.2
resolution: "ignore@npm:5.3.2"
@@ -1127,13 +886,6 @@ __metadata:
languageName: node
linkType: hard
"ini@npm:^6.0.0":
version: 6.0.0
resolution: "ini@npm:6.0.0"
checksum: 10c0/9a7f55f306e2b25b41ae67c8b526e8f4673f057b70852b9025816ef4f15f07bf1ba35ed68ea4471ff7b31718f7ef1bc50d709f8d03cb012e10a3135eb99c7206
languageName: node
linkType: hard
"ini@npm:~4.1.0":
version: 4.1.3
resolution: "ini@npm:4.1.3"
@@ -1141,13 +893,6 @@ __metadata:
languageName: node
linkType: hard
"ip-address@npm:^10.0.1":
version: 10.1.0
resolution: "ip-address@npm:10.1.0"
checksum: 10c0/0103516cfa93f6433b3bd7333fa876eb21263912329bfa47010af5e16934eeeff86f3d2ae700a3744a137839ddfad62b900c7a445607884a49b5d1e32a3d7566
languageName: node
linkType: hard
"is-alphabetical@npm:^2.0.0":
version: 2.0.1
resolution: "is-alphabetical@npm:2.0.1"
@@ -1209,13 +954,6 @@ __metadata:
languageName: node
linkType: hard
"isexe@npm:^4.0.0":
version: 4.0.0
resolution: "isexe@npm:4.0.0"
checksum: 10c0/5884815115bceac452877659a9c7726382531592f43dc29e5d48b7c4100661aed54018cb90bd36cb2eaeba521092570769167acbb95c18d39afdccbcca06c5ce
languageName: node
linkType: hard
"istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.0":
version: 3.2.2
resolution: "istanbul-lib-coverage@npm:3.2.2"
@@ -1317,13 +1055,6 @@ __metadata:
languageName: node
linkType: hard
"kubernetes-types@npm:^1.30.0":
version: 1.30.0
resolution: "kubernetes-types@npm:1.30.0"
checksum: 10c0/de3641e4f50cfc123c4102a73c12932e1db8e51783c7cae4ea8ad3561bd56fab0f1c2346801f84a4c36aae8cea0b25d21e9514cc0fcecd4d64b1314043263076
languageName: node
linkType: hard
"levn@npm:^0.4.1":
version: 0.4.1
resolution: "levn@npm:0.4.1"
@@ -1366,13 +1097,6 @@ __metadata:
languageName: node
linkType: hard
"lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1":
version: 11.3.5
resolution: "lru-cache@npm:11.3.5"
checksum: 10c0/5b54ef7b88afb4bd25b7a778f1b2b1cde32d9770913e530da34ab203cf0442413bcaa6e372800cbab9562557a4480e4d8bf32e3a368bb5a91b12218eca085c66
languageName: node
linkType: hard
"make-dir@npm:^4.0.0":
version: 4.0.0
resolution: "make-dir@npm:4.0.0"
@@ -1382,26 +1106,6 @@ __metadata:
languageName: node
linkType: hard
"make-fetch-happen@npm:^15.0.0":
version: 15.0.5
resolution: "make-fetch-happen@npm:15.0.5"
dependencies:
"@gar/promise-retry": "npm:^1.0.0"
"@npmcli/agent": "npm:^4.0.0"
"@npmcli/redact": "npm:^4.0.0"
cacache: "npm:^20.0.1"
http-cache-semantics: "npm:^4.1.1"
minipass: "npm:^7.0.2"
minipass-fetch: "npm:^5.0.0"
minipass-flush: "npm:^1.0.5"
minipass-pipeline: "npm:^1.2.4"
negotiator: "npm:^1.0.0"
proc-log: "npm:^6.0.0"
ssri: "npm:^13.0.0"
checksum: 10c0/527580eb5e5476e6ad07a4e3bd017d13e935f4be815674b442081ae5a721c13d3af5715006619e6be79a85723067e047f83a0c9e699f41d8cec43609a8de4f7b
languageName: node
linkType: hard
"markdown-it@npm:~14.1.1":
version: 14.1.1
resolution: "markdown-it@npm:14.1.1"
@@ -1784,82 +1488,13 @@ __metadata:
languageName: node
linkType: hard
"minipass-collect@npm:^2.0.1":
version: 2.0.1
resolution: "minipass-collect@npm:2.0.1"
dependencies:
minipass: "npm:^7.0.3"
checksum: 10c0/5167e73f62bb74cc5019594709c77e6a742051a647fe9499abf03c71dca75515b7959d67a764bdc4f8b361cf897fbf25e2d9869ee039203ed45240f48b9aa06e
languageName: node
linkType: hard
"minipass-fetch@npm:^5.0.0":
version: 5.0.2
resolution: "minipass-fetch@npm:5.0.2"
dependencies:
iconv-lite: "npm:^0.7.2"
minipass: "npm:^7.0.3"
minipass-sized: "npm:^2.0.0"
minizlib: "npm:^3.0.1"
dependenciesMeta:
iconv-lite:
optional: true
checksum: 10c0/ce4ab9f21cfabaead2097d95dd33f485af8072fbc6b19611bce694965393453a1639d641c2bcf1c48f2ea7d41ea7fab8278373f1d0bee4e63b0a5b2cdd0ef649
languageName: node
linkType: hard
"minipass-flush@npm:^1.0.5":
version: 1.0.7
resolution: "minipass-flush@npm:1.0.7"
dependencies:
minipass: "npm:^3.0.0"
checksum: 10c0/960915c02aa0991662c37c404517dd93708d17f96533b2ca8c1e776d158715d8107c5ced425ffc61674c167d93607f07f48a83c139ce1057f8781e5dfb4b90c2
languageName: node
linkType: hard
"minipass-pipeline@npm:^1.2.4":
version: 1.2.4
resolution: "minipass-pipeline@npm:1.2.4"
dependencies:
minipass: "npm:^3.0.0"
checksum: 10c0/cbda57cea20b140b797505dc2cac71581a70b3247b84480c1fed5ca5ba46c25ecc25f68bfc9e6dcb1a6e9017dab5c7ada5eab73ad4f0a49d84e35093e0c643f2
languageName: node
linkType: hard
"minipass-sized@npm:^2.0.0":
version: 2.0.0
resolution: "minipass-sized@npm:2.0.0"
dependencies:
minipass: "npm:^7.1.2"
checksum: 10c0/f9201696a6f6d68610d04c9c83e3d2e5cb9c026aae1c8cbf7e17f386105cb79c1bb088dbc21bf0b1eb4f3fb5df384fd1e7aa3bf1f33868c416ae8c8a92679db8
languageName: node
linkType: hard
"minipass@npm:^3.0.0":
version: 3.3.6
resolution: "minipass@npm:3.3.6"
dependencies:
yallist: "npm:^4.0.0"
checksum: 10c0/a114746943afa1dbbca8249e706d1d38b85ed1298b530f5808ce51f8e9e941962e2a5ad2e00eae7dd21d8a4aae6586a66d4216d1a259385e9d0358f0c1eba16c
languageName: node
linkType: hard
"minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2, minipass@npm:^7.1.3":
"minipass@npm:^7.1.2, minipass@npm:^7.1.3":
version: 7.1.3
resolution: "minipass@npm:7.1.3"
checksum: 10c0/539da88daca16533211ea5a9ee98dc62ff5742f531f54640dd34429e621955e91cc280a91a776026264b7f9f6735947629f920944e9c1558369e8bf22eb33fbb
languageName: node
linkType: hard
"minizlib@npm:^3.0.1, minizlib@npm:^3.1.0":
version: 3.1.0
resolution: "minizlib@npm:3.1.0"
dependencies:
minipass: "npm:^7.1.2"
checksum: 10c0/5aad75ab0090b8266069c9aabe582c021ae53eb33c6c691054a13a45db3b4f91a7fb1bd79151e6b4e9e9a86727b522527c0a06ec7d45206b745d54cd3097bcec
languageName: node
linkType: hard
"ms@npm:^2.1.3":
version: 2.1.3
resolution: "ms@npm:2.1.3"
@@ -1867,56 +1502,6 @@ __metadata:
languageName: node
linkType: hard
"msgpackr-extract@npm:^3.0.2":
version: 3.0.3
resolution: "msgpackr-extract@npm:3.0.3"
dependencies:
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "npm:3.0.3"
"@msgpackr-extract/msgpackr-extract-darwin-x64": "npm:3.0.3"
"@msgpackr-extract/msgpackr-extract-linux-arm": "npm:3.0.3"
"@msgpackr-extract/msgpackr-extract-linux-arm64": "npm:3.0.3"
"@msgpackr-extract/msgpackr-extract-linux-x64": "npm:3.0.3"
"@msgpackr-extract/msgpackr-extract-win32-x64": "npm:3.0.3"
node-gyp: "npm:latest"
node-gyp-build-optional-packages: "npm:5.2.2"
dependenciesMeta:
"@msgpackr-extract/msgpackr-extract-darwin-arm64":
optional: true
"@msgpackr-extract/msgpackr-extract-darwin-x64":
optional: true
"@msgpackr-extract/msgpackr-extract-linux-arm":
optional: true
"@msgpackr-extract/msgpackr-extract-linux-arm64":
optional: true
"@msgpackr-extract/msgpackr-extract-linux-x64":
optional: true
"@msgpackr-extract/msgpackr-extract-win32-x64":
optional: true
bin:
download-msgpackr-prebuilds: bin/download-prebuilds.js
checksum: 10c0/e504fd8bf86a29d7527c83776530ee6dc92dcb0273bb3679fd4a85173efead7f0ee32fb82c8410a13c33ef32828c45f81118ffc0fbed5d6842e72299894623b4
languageName: node
linkType: hard
"msgpackr@npm:^1.11.9":
version: 1.11.10
resolution: "msgpackr@npm:1.11.10"
dependencies:
msgpackr-extract: "npm:^3.0.2"
dependenciesMeta:
msgpackr-extract:
optional: true
checksum: 10c0/fa5b8b90661cc66127d4550bc3757d0e72fe3217a47f952acd0df647afb4593ffde0bbdb1c9f5a13b9df15d5a170a570a59728dd7ede9b0d711e20e4f970fa82
languageName: node
linkType: hard
"multipasta@npm:^0.2.7":
version: 0.2.7
resolution: "multipasta@npm:0.2.7"
checksum: 10c0/15917ac88aeefa5b8afac44b90d1e9d0d0ec7148b51e0766f07a69a220ecebcb6404539a856c45aa85a3d7fe517bc58febe81437146705f17ecd2961dc0b9fa5
languageName: node
linkType: hard
"natural-compare@npm:^1.4.0":
version: 1.4.0
resolution: "natural-compare@npm:1.4.0"
@@ -1924,57 +1509,6 @@ __metadata:
languageName: node
linkType: hard
"negotiator@npm:^1.0.0":
version: 1.0.0
resolution: "negotiator@npm:1.0.0"
checksum: 10c0/4c559dd52669ea48e1914f9d634227c561221dd54734070791f999c52ed0ff36e437b2e07d5c1f6e32909fc625fe46491c16e4a8f0572567d4dd15c3a4fda04b
languageName: node
linkType: hard
"node-gyp-build-optional-packages@npm:5.2.2":
version: 5.2.2
resolution: "node-gyp-build-optional-packages@npm:5.2.2"
dependencies:
detect-libc: "npm:^2.0.1"
bin:
node-gyp-build-optional-packages: bin.js
node-gyp-build-optional-packages-optional: optional.js
node-gyp-build-optional-packages-test: build-test.js
checksum: 10c0/c81128c6f91873381be178c5eddcbdf66a148a6a89a427ce2bcd457593ce69baf2a8662b6d22cac092d24aa9c43c230dec4e69b3a0da604503f4777cd77e282b
languageName: node
linkType: hard
"node-gyp@npm:latest":
version: 12.2.0
resolution: "node-gyp@npm:12.2.0"
dependencies:
env-paths: "npm:^2.2.0"
exponential-backoff: "npm:^3.1.1"
graceful-fs: "npm:^4.2.6"
make-fetch-happen: "npm:^15.0.0"
nopt: "npm:^9.0.0"
proc-log: "npm:^6.0.0"
semver: "npm:^7.3.5"
tar: "npm:^7.5.4"
tinyglobby: "npm:^0.2.12"
which: "npm:^6.0.0"
bin:
node-gyp: bin/node-gyp.js
checksum: 10c0/3ed046746a5a7d90950cd8b0547332b06598443f31fe213ef4332a7174c7b7d259e1704835feda79b87d3f02e59d7791842aac60642ede4396ab25fdf0f8f759
languageName: node
linkType: hard
"nopt@npm:^9.0.0":
version: 9.0.0
resolution: "nopt@npm:9.0.0"
dependencies:
abbrev: "npm:^4.0.0"
bin:
nopt: bin/nopt.js
checksum: 10c0/1822eb6f9b020ef6f7a7516d7b64a8036e09666ea55ac40416c36e4b2b343122c3cff0e2f085675f53de1d2db99a2a89a60ccea1d120bcd6a5347bf6ceb4a7fd
languageName: node
linkType: hard
"optionator@npm:^0.9.3":
version: 0.9.4
resolution: "optionator@npm:0.9.4"
@@ -2007,13 +1541,6 @@ __metadata:
languageName: node
linkType: hard
"p-map@npm:^7.0.2":
version: 7.0.4
resolution: "p-map@npm:7.0.4"
checksum: 10c0/a5030935d3cb2919d7e89454d1ce82141e6f9955413658b8c9403cfe379283770ed3048146b44cde168aa9e8c716505f196d5689db0ae3ce9a71521a2fef3abd
languageName: node
linkType: hard
"parent-module@npm:^1.0.0":
version: 1.0.1
resolution: "parent-module@npm:1.0.1"
@@ -2062,7 +1589,7 @@ __metadata:
languageName: node
linkType: hard
"picomatch@npm:^4.0.3, picomatch@npm:^4.0.4":
"picomatch@npm:^4.0.3":
version: 4.0.4
resolution: "picomatch@npm:4.0.4"
checksum: 10c0/e2c6023372cc7b5764719a5ffb9da0f8e781212fa7ca4bd0562db929df8e117460f00dff3cb7509dacfc06b86de924b247f504d0ce1806a37fac4633081466b0
@@ -2076,13 +1603,6 @@ __metadata:
languageName: node
linkType: hard
"proc-log@npm:^6.0.0":
version: 6.1.0
resolution: "proc-log@npm:6.1.0"
checksum: 10c0/4f178d4062733ead9d71a9b1ab24ebcecdfe2250916a5b1555f04fe2eda972a0ec76fbaa8df1ad9c02707add6749219d118a4fc46dc56bdfe4dde4b47d80bb82
languageName: node
linkType: hard
"punycode.js@npm:^2.3.1":
version: 2.3.1
resolution: "punycode.js@npm:2.3.1"
@@ -2097,13 +1617,6 @@ __metadata:
languageName: node
linkType: hard
"pure-rand@npm:^8.0.0":
version: 8.4.0
resolution: "pure-rand@npm:8.4.0"
checksum: 10c0/6414bbc1c6f45fb774173431c7205e79783b77cfae0e2145e741b6999363554dbd2f4210d2a5bc08683e0b2f6823198c9308766b1d0911e1dccd7beb8842f860
languageName: node
linkType: hard
"require-directory@npm:^2.1.1":
version: 2.1.1
resolution: "require-directory@npm:2.1.1"
@@ -2139,14 +1652,7 @@ __metadata:
languageName: node
linkType: hard
"safer-buffer@npm:>= 2.1.2 < 3.0.0":
version: 2.1.2
resolution: "safer-buffer@npm:2.1.2"
checksum: 10c0/7e3c8b2e88a1841c9671094bbaeebd94448111dd90a81a1f606f3f67708a6ec57763b3b47f06da09fc6054193e0e6709e77325415dc8422b04497a8070fa02d4
languageName: node
linkType: hard
"semver@npm:^7.3.5, semver@npm:^7.5.3":
"semver@npm:^7.5.3":
version: 7.7.4
resolution: "semver@npm:7.7.4"
bin:
@@ -2178,13 +1684,6 @@ __metadata:
languageName: node
linkType: hard
"smart-buffer@npm:^4.2.0":
version: 4.2.0
resolution: "smart-buffer@npm:4.2.0"
checksum: 10c0/a16775323e1404dd43fabafe7460be13a471e021637bc7889468eb45ce6a6b207261f454e4e530a19500cc962c4cc5348583520843b363f4193cee5c00e1e539
languageName: node
linkType: hard
"smol-toml@npm:~1.6.0":
version: 1.6.1
resolution: "smol-toml@npm:1.6.1"
@@ -2192,27 +1691,6 @@ __metadata:
languageName: node
linkType: hard
"socks-proxy-agent@npm:^8.0.3":
version: 8.0.5
resolution: "socks-proxy-agent@npm:8.0.5"
dependencies:
agent-base: "npm:^7.1.2"
debug: "npm:^4.3.4"
socks: "npm:^2.8.3"
checksum: 10c0/5d2c6cecba6821389aabf18728325730504bf9bb1d9e342e7987a5d13badd7a98838cc9a55b8ed3cb866ad37cc23e1086f09c4d72d93105ce9dfe76330e9d2a6
languageName: node
linkType: hard
"socks@npm:^2.8.3":
version: 2.8.7
resolution: "socks@npm:2.8.7"
dependencies:
ip-address: "npm:^10.0.1"
smart-buffer: "npm:^4.2.0"
checksum: 10c0/2805a43a1c4bcf9ebf6e018268d87b32b32b06fbbc1f9282573583acc155860dc361500f89c73bfbb157caa1b4ac78059eac0ef15d1811eb0ca75e0bdadbc9d2
languageName: node
linkType: hard
"sql.js@npm:^1.14.1":
version: 1.14.1
resolution: "sql.js@npm:1.14.1"
@@ -2220,15 +1698,6 @@ __metadata:
languageName: node
linkType: hard
"ssri@npm:^13.0.0":
version: 13.0.1
resolution: "ssri@npm:13.0.1"
dependencies:
minipass: "npm:^7.0.3"
checksum: 10c0/cf6408a18676c57ff2ed06b8a20dc64bb3e748e5c7e095332e6aecaa2b8422b1e94a739a8453bf65156a8a47afe23757ba4ab52d3ea3b62322dc40875763e17a
languageName: node
linkType: hard
"string-width@npm:8.1.0":
version: 8.1.0
resolution: "string-width@npm:8.1.0"
@@ -2284,19 +1753,6 @@ __metadata:
languageName: node
linkType: hard
"tar@npm:^7.5.4":
version: 7.5.13
resolution: "tar@npm:7.5.13"
dependencies:
"@isaacs/fs-minipass": "npm:^4.0.0"
chownr: "npm:^3.0.0"
minipass: "npm:^7.1.2"
minizlib: "npm:^3.1.0"
yallist: "npm:^5.0.0"
checksum: 10c0/5c65b8084799bde7a791593a1c1a45d3d6ee98182e3700b24c247b7b8f8654df4191642abbdb07ff25043d45dcff35620827c3997b88ae6c12040f64bed5076b
languageName: node
linkType: hard
"test-exclude@npm:^8.0.0":
version: 8.0.0
resolution: "test-exclude@npm:8.0.0"
@@ -2308,16 +1764,6 @@ __metadata:
languageName: node
linkType: hard
"tinyglobby@npm:^0.2.12":
version: 0.2.16
resolution: "tinyglobby@npm:0.2.16"
dependencies:
fdir: "npm:^6.5.0"
picomatch: "npm:^4.0.4"
checksum: 10c0/f2e09fd93dd95c41e522113b686ff6f7c13020962f8698a864a257f3d7737599afc47722b7ab726e12f8a813f779906187911ff8ee6701ede65072671a7e934b
languageName: node
linkType: hard
"tinyglobby@npm:~0.2.15":
version: 0.2.15
resolution: "tinyglobby@npm:0.2.15"
@@ -2328,13 +1774,6 @@ __metadata:
languageName: node
linkType: hard
"toml@npm:^4.1.1":
version: 4.1.1
resolution: "toml@npm:4.1.1"
checksum: 10c0/077bc02ac1ce82091ea073f675d7e2a1df487d1b18bbc7e653daba4956d545954b7095e979b8792f0837339b901ee190ad4464342e5e377c36bbdeca8903e079
languageName: node
linkType: hard
"type-check@npm:^0.4.0, type-check@npm:~0.4.0":
version: 0.4.0
resolution: "type-check@npm:0.4.0"
@@ -2387,15 +1826,6 @@ __metadata:
languageName: node
linkType: hard
"uuid@npm:^13.0.0":
version: 13.0.0
resolution: "uuid@npm:13.0.0"
bin:
uuid: dist-node/bin/uuid
checksum: 10c0/950e4c18d57fef6c69675344f5700a08af21e26b9eff2bf2180427564297368c538ea11ac9fb2e6528b17fc3966a9fd2c5049361b0b63c7d654f3c550c9b3d67
languageName: node
linkType: hard
"v8-to-istanbul@npm:^9.0.0":
version: 9.3.0
resolution: "v8-to-istanbul@npm:9.3.0"
@@ -2418,17 +1848,6 @@ __metadata:
languageName: node
linkType: hard
"which@npm:^6.0.0":
version: 6.0.1
resolution: "which@npm:6.0.1"
dependencies:
isexe: "npm:^4.0.0"
bin:
node-which: bin/which.js
checksum: 10c0/7e710e54ea36d2d6183bee2f9caa27a3b47b9baf8dee55a199b736fcf85eab3b9df7556fca3d02b50af7f3dfba5ea3a45644189836df06267df457e354da66d5
languageName: node
linkType: hard
"word-wrap@npm:^1.2.5":
version: 1.2.5
resolution: "word-wrap@npm:1.2.5"
@@ -2454,29 +1873,6 @@ __metadata:
languageName: node
linkType: hard
"yallist@npm:^4.0.0":
version: 4.0.0
resolution: "yallist@npm:4.0.0"
checksum: 10c0/2286b5e8dbfe22204ab66e2ef5cc9bbb1e55dfc873bbe0d568aa943eb255d131890dfd5bf243637273d31119b870f49c18fcde2c6ffbb7a7a092b870dc90625a
languageName: node
linkType: hard
"yallist@npm:^5.0.0":
version: 5.0.0
resolution: "yallist@npm:5.0.0"
checksum: 10c0/a499c81ce6d4a1d260d4ea0f6d49ab4da09681e32c3f0472dee16667ed69d01dae63a3b81745a24bd78476ec4fcf856114cb4896ace738e01da34b2c42235416
languageName: node
linkType: hard
"yaml@npm:^2.8.3":
version: 2.8.3
resolution: "yaml@npm:2.8.3"
bin:
yaml: bin.mjs
checksum: 10c0/ddff0e11c1b467728d7eb4633db61c5f5de3d8e9373cf84d08fb0cdee03e1f58f02b9f1c51a4a8a865751695addbd465a77f73f1079be91fe5493b29c305fd77
languageName: node
linkType: hard
"yargs-parser@npm:^21.1.1":
version: 21.1.1
resolution: "yargs-parser@npm:21.1.1"