From 10114714be2d3665245656ff674917f1c922a7f7 Mon Sep 17 00:00:00 2001 From: dum3r <131576509+dum3r@users.noreply.github.com> Date: Thu, 9 Apr 2026 07:47:14 +0300 Subject: [PATCH 1/2] feat: ingest WSL agent homes on Windows --- src/data.js | 124 ++++++++++++++- test/data.test.js | 389 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 505 insertions(+), 8 deletions(-) create mode 100644 test/data.test.js diff --git a/src/data.js b/src/data.js index 557d875..fed0666 100644 --- a/src/data.js +++ b/src/data.js @@ -5,9 +5,78 @@ const { execSync, execFileSync } = require('child_process'); // ── Constants ────────────────────────────────────────────── -// Detect WSL and find Windows user home for cross-OS data access +function normalizeProjectPath(value) { + if (!value || typeof value !== 'string') return value || ''; + let normalized = value.trim(); + if (!normalized) return ''; + if (/^\\\\\?\\UNC\\/i.test(normalized)) { + normalized = '\\\\' + normalized.slice(8); + } else if (/^\\\\\?\\[A-Za-z]:\\/i.test(normalized)) { + normalized = normalized.slice(4); + } + return normalized; +} + +function parseWslDistroList(raw) { + const text = Buffer.isBuffer(raw) + ? raw.toString('utf16le') + : String(raw || '').replace(/\0/g, ''); + return text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +} + +function buildWslUncPath(distro, linuxPath) { + if (!distro || !linuxPath || !linuxPath.startsWith('/')) return ''; + return '\\\\wsl$\\' + distro + linuxPath.replace(/\//g, '\\'); +} + +function detectWindowsWslHomes() { + if (process.platform !== 'win32') return []; + const homes = []; + try { + const distros = parseWslDistroList(execFileSync('wsl.exe', ['-l', '-q'], { + encoding: 'buffer', + timeout: 3000, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + })); + + for (const distro of distros) { + try { + const linuxHome = String(execFileSync('wsl.exe', ['-d', distro, 'sh', '-lc', 'printf %s "$HOME"'], { + encoding: 'utf8', + timeout: 3000, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + }) || '').trim(); + if (!linuxHome || !linuxHome.startsWith('/')) continue; + const uncHome = buildWslUncPath(distro, linuxHome); + if (!uncHome) continue; + const hasClaude = fs.existsSync(path.join(uncHome, '.claude')); + const hasCodex = fs.existsSync(path.join(uncHome, '.codex')); + if (hasClaude || hasCodex) homes.push(uncHome); + } catch {} + } + } catch {} + return homes; +} + +// Detect cross-OS homes for session data access function detectHomes() { - const homes = [os.homedir()]; + const homes = []; + const seen = new Set(); + const addHome = (home) => { + const normalized = normalizeProjectPath(home); + if (!normalized) return; + const key = process.platform === 'win32' ? normalized.toLowerCase() : normalized; + if (seen.has(key)) return; + seen.add(key); + homes.push(normalized); + }; + + addHome(os.homedir()); // WSL: also check Windows-side home dirs if (process.platform === 'linux' && fs.existsSync('/mnt/c/Users')) { try { @@ -16,22 +85,21 @@ function detectHomes() { // Convert C:\Users\foo to /mnt/c/Users/foo const drive = winUser[0].toLowerCase(); const winPath = '/mnt/' + drive + winUser.slice(2).replace(/\\/g, '/'); - if (fs.existsSync(winPath) && !homes.includes(winPath)) { - homes.push(winPath); - } + if (fs.existsSync(winPath)) addHome(winPath); } } catch { // Fallback: scan /mnt/c/Users/ for directories with .claude try { for (const u of fs.readdirSync('/mnt/c/Users')) { const candidate = '/mnt/c/Users/' + u; - if (fs.existsSync(path.join(candidate, '.claude'))) { - if (!homes.includes(candidate)) homes.push(candidate); - } + if (fs.existsSync(path.join(candidate, '.claude'))) addHome(candidate); } } catch {} } } + if (process.platform === 'win32') { + for (const home of detectWindowsWslHomes()) addHome(home); + } return homes; } @@ -79,6 +147,34 @@ function readLines(filePath) { return fs.readFileSync(filePath, 'utf8').split('\n').map(l => l.replace(/\r$/, '')).filter(Boolean); } +function shortenHomePath(value, homes = ALL_HOMES) { + value = normalizeProjectPath(value); + if (!value || typeof value !== 'string') return value || ''; + const valueLower = value.toLowerCase(); + for (const homeRaw of homes) { + const variants = []; + const normalizedHome = normalizeProjectPath(homeRaw); + if (normalizedHome) variants.push(normalizedHome); + const wslMatch = normalizedHome && normalizedHome.match(/^\/mnt\/([a-z])\/(.*)$/i); + if (wslMatch) { + variants.push(wslMatch[1].toUpperCase() + ':\\' + wslMatch[2].replace(/\//g, '\\')); + } + const uncWslMatch = normalizedHome && normalizedHome.match(/^\\\\wsl\$\\[^\\]+\\(.+)$/i); + if (uncWslMatch) { + variants.push('/' + uncWslMatch[1].replace(/\\/g, '/')); + } + for (const home of variants) { + const homeLower = home.toLowerCase(); + if (valueLower === homeLower) return '~'; + if (valueLower.startsWith(homeLower)) { + const nextChar = value.charAt(home.length); + if (nextChar !== '\\' && nextChar !== '/') continue; + return '~' + value.slice(home.length); + } + } + } + return value; +} // OpenCode built-in tools that should NOT be treated as MCP servers const OPENCODE_BUILTIN_TOOLS = new Set([ 'read', 'write', 'edit', 'bash', 'glob', 'grep', 'task', 'todowrite', @@ -3137,4 +3233,16 @@ module.exports = { KIRO_DB, HISTORY_FILE, PROJECTS_DIR, + __test: { + parseWslDistroList, + buildWslUncPath, + normalizeProjectPath, + shortenHomePath, + mergeClaudeSessionDetail, + getCodexTokenCountUsage, + computeCodexCostFromJsonlLines, + mergeCodexSession, + parseSessionIdFromCommandLine, + parseWindowsCwdFromCommandLine, + }, }; diff --git a/test/data.test.js b/test/data.test.js new file mode 100644 index 0000000..ed80052 --- /dev/null +++ b/test/data.test.js @@ -0,0 +1,389 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const data = require('../src/data'); + +const { + parseWslDistroList, + buildWslUncPath, + normalizeProjectPath, + shortenHomePath, + mergeClaudeSessionDetail, + computeCodexCostFromJsonlLines, + mergeCodexSession, + parseSessionIdFromCommandLine, + parseWindowsCwdFromCommandLine, +} = data.__test; + +function readFixtureLines(name) { + return fs.readFileSync(path.join(__dirname, 'fixtures', name), 'utf8').split(/\r?\n/).filter(Boolean); +} + +function tokenCountLine({ + timestamp = '2026-03-11T09:13:22.441Z', + last = {}, + total = null, + contextWindow = 258400, +}) { + return JSON.stringify({ + timestamp, + type: 'event_msg', + payload: { + type: 'token_count', + info: total === null && last === null ? null : { + total_token_usage: total, + last_token_usage: last, + model_context_window: contextWindow, + }, + rate_limits: null, + }, + }); +} + +test('computeCodexCostFromJsonlLines uses real token_count usage', () => { + const line = tokenCountLine({ + last: { + input_tokens: 1000, + cached_input_tokens: 400, + output_tokens: 200, + reasoning_output_tokens: 50, + }, + total: { + input_tokens: 1000, + cached_input_tokens: 400, + output_tokens: 200, + reasoning_output_tokens: 50, + }, + }); + + const result = computeCodexCostFromJsonlLines([line], 0); + assert.equal(result.estimated, false); + assert.equal(result.model, 'codex-mini-latest'); + assert.equal(result.inputTokens, 600); + assert.equal(result.cacheReadTokens, 400); + assert.equal(result.outputTokens, 250); + assert.equal(result.contextTurnCount, 1); + assert.ok(result.cost > 0); +}); + +test('computeCodexCostFromJsonlLines parses real new-format Codex fixture', () => { + const lines = readFixtureLines('codex-new-format.jsonl'); + const result = computeCodexCostFromJsonlLines(lines, 0); + assert.equal(result.estimated, false); + assert.equal(result.model, 'codex-mini-latest'); + assert.equal(result.inputTokens, 33102); + assert.equal(result.cacheReadTokens, 38784); + assert.equal(result.outputTokens, 1727); + assert.equal(result.contextTurnCount, 2); + assert.ok(result.contextPctSum > 27 && result.contextPctSum < 28); +}); + +test('computeCodexCostFromJsonlLines dedupes exact duplicate token_count events', () => { + const line = tokenCountLine({ + last: { + input_tokens: 900, + cached_input_tokens: 300, + output_tokens: 100, + reasoning_output_tokens: 25, + }, + total: { + input_tokens: 900, + cached_input_tokens: 300, + output_tokens: 100, + reasoning_output_tokens: 25, + }, + }); + + const once = computeCodexCostFromJsonlLines([line], 0); + const duped = computeCodexCostFromJsonlLines([line, line], 0); + assert.deepEqual(duped, once); +}); + +test('computeCodexCostFromJsonlLines counts distinct events with same last usage but different totals', () => { + const first = tokenCountLine({ + timestamp: '2026-03-11T09:13:22.441Z', + last: { + input_tokens: 500, + cached_input_tokens: 100, + output_tokens: 80, + reasoning_output_tokens: 20, + }, + total: { + input_tokens: 1000, + cached_input_tokens: 500, + output_tokens: 80, + reasoning_output_tokens: 20, + }, + }); + const second = tokenCountLine({ + timestamp: '2026-03-11T09:13:22.441Z', + last: { + input_tokens: 500, + cached_input_tokens: 100, + output_tokens: 80, + reasoning_output_tokens: 20, + }, + total: { + input_tokens: 1500, + cached_input_tokens: 600, + output_tokens: 160, + reasoning_output_tokens: 40, + }, + }); + + const result = computeCodexCostFromJsonlLines([first, second], 0); + assert.equal(result.inputTokens, 800); + assert.equal(result.cacheReadTokens, 200); + assert.equal(result.outputTokens, 200); + assert.equal(result.contextTurnCount, 2); +}); + +test('computeCodexCostFromJsonlLines does not dedupe same-timestamp events when total usage is absent', () => { + const first = tokenCountLine({ + timestamp: '2026-03-11T09:13:22.441Z', + last: { + input_tokens: 500, + cached_input_tokens: 100, + output_tokens: 80, + reasoning_output_tokens: 20, + }, + total: null, + }); + const second = tokenCountLine({ + timestamp: '2026-03-11T09:13:22.441Z', + last: { + input_tokens: 500, + cached_input_tokens: 100, + output_tokens: 80, + reasoning_output_tokens: 20, + }, + total: null, + }); + + const result = computeCodexCostFromJsonlLines([first, second], 0); + assert.equal(result.inputTokens, 800); + assert.equal(result.cacheReadTokens, 200); + assert.equal(result.outputTokens, 200); + assert.equal(result.contextTurnCount, 2); +}); + +test('computeCodexCostFromJsonlLines dedupes interleaved duplicate signatures only once', () => { + const duplicate = tokenCountLine({ + timestamp: '2026-03-11T09:13:22.441Z', + last: { + input_tokens: 700, + cached_input_tokens: 200, + output_tokens: 60, + reasoning_output_tokens: 10, + }, + total: { + input_tokens: 1700, + cached_input_tokens: 700, + output_tokens: 60, + reasoning_output_tokens: 10, + }, + }); + const distinct = tokenCountLine({ + timestamp: '2026-03-11T09:13:23.111Z', + last: { + input_tokens: 900, + cached_input_tokens: 400, + output_tokens: 50, + reasoning_output_tokens: 0, + }, + total: { + input_tokens: 2600, + cached_input_tokens: 1100, + output_tokens: 110, + reasoning_output_tokens: 10, + }, + }); + + const result = computeCodexCostFromJsonlLines([duplicate, distinct, duplicate, duplicate], 0); + assert.equal(result.inputTokens, 1000); + assert.equal(result.cacheReadTokens, 600); + assert.equal(result.outputTokens, 120); + assert.equal(result.contextTurnCount, 2); +}); + +test('computeCodexCostFromJsonlLines falls back to estimate when token_count is absent', () => { + const result = computeCodexCostFromJsonlLines([], 400); + assert.equal(result.estimated, true); + assert.equal(result.model, 'codex-mini-latest-estimated'); + assert.equal(result.inputTokens, 30); + assert.equal(result.outputTokens, 70); + assert.ok(result.cost > 0); +}); + +test('computeCodexCostFromJsonlLines falls back on real old-format Codex fixture without token_count', () => { + const lines = readFixtureLines('codex-old-format.jsonl'); + const totalSize = fs.statSync(path.join(__dirname, 'fixtures', 'codex-old-format.jsonl')).size; + const result = computeCodexCostFromJsonlLines(lines, totalSize); + assert.equal(result.estimated, true); + assert.equal(result.model, 'codex-mini-latest-estimated'); + assert.ok(result.cost > 0); +}); + +test('computeCodexCostFromJsonlLines clamps cached_input_tokens above input_tokens', () => { + const line = tokenCountLine({ + last: { + input_tokens: 100, + cached_input_tokens: 150, + output_tokens: 10, + reasoning_output_tokens: 5, + }, + total: { + input_tokens: 100, + cached_input_tokens: 150, + output_tokens: 10, + reasoning_output_tokens: 5, + }, + }); + + const result = computeCodexCostFromJsonlLines([line], 0); + assert.equal(result.inputTokens, 0); + assert.equal(result.cacheReadTokens, 150); + assert.equal(result.outputTokens, 15); +}); + +test('mergeCodexSession keeps primary precedence when rank is 0', () => { + const existing = { + id: 'same', + project: 'C:\\primary', + first_message: 'primary', + _session_file: 'primary.jsonl', + _codex_root: 'primary-root', + codex_source: 'primary', + _codex_source_rank: 0, + mcp_servers: ['a'], + skills: ['one'], + }; + const candidate = { + id: 'same', + project: 'C:\\archive', + first_message: 'archive', + _session_file: 'archive.jsonl', + _codex_root: 'archive-root', + codex_source: 'archive', + _codex_source_rank: 1, + mcp_servers: ['b'], + skills: ['two'], + }; + + const merged = mergeCodexSession(existing, candidate); + assert.equal(merged.project, 'C:\\primary'); + assert.equal(merged.first_message, 'primary'); + assert.equal(merged._session_file, 'primary.jsonl'); + assert.equal(merged.codex_source, 'primary'); + assert.equal(merged._codex_source_rank, 0); + assert.deepEqual(merged.mcp_servers.sort(), ['a', 'b']); + assert.deepEqual(merged.skills.sort(), ['one', 'two']); +}); + +test('mergeCodexSession does not replace existing session file with empty candidate file', () => { + const existing = { + _session_file: 'primary.jsonl', + _codex_source_rank: 1, + project: '', + project_short: '', + first_message: '', + }; + const candidate = { + _session_file: '', + _codex_source_rank: 0, + project: 'C:\\primary', + project_short: 'C:\\primary', + first_message: 'newer', + }; + + const merged = mergeCodexSession(existing, candidate); + assert.equal(merged._session_file, 'primary.jsonl'); + assert.equal(merged.project, 'C:\\primary'); + assert.equal(merged.first_message, 'newer'); +}); + +test('parseSessionIdFromCommandLine extracts resume UUID', () => { + const cmd = 'cmd /k "cd C:\\1_Projects && codex resume 019d6dc8-03d4-72e0-8239-bda72acb65fb"'; + assert.equal(parseSessionIdFromCommandLine(cmd), '019d6dc8-03d4-72e0-8239-bda72acb65fb'); +}); + +test('parseWindowsCwdFromCommandLine extracts cwd from cmd wrapper', () => { + const cmd = 'cmd /k "cd C:\\1_Projects\\codedash && codex resume 019d6dc8-03d4-72e0-8239-bda72acb65fb"'; + assert.equal(parseWindowsCwdFromCommandLine(cmd), 'C:\\1_Projects\\codedash'); +}); + +test('normalizeProjectPath strips Windows extended-length prefixes', () => { + assert.equal(normalizeProjectPath('\\\\?\\C:\\1_Projects\\codedash'), 'C:\\1_Projects\\codedash'); + assert.equal(normalizeProjectPath('\\\\?\\UNC\\server\\share\\repo'), '\\\\server\\share\\repo'); +}); + +test('shortenHomePath matches normalized home roots', () => { + const value = '\\\\?\\C:\\Users\\JurijsBaranovs\\Projects\\codedash'; + const homes = ['C:\\Users\\JurijsBaranovs']; + assert.equal(shortenHomePath(value, homes), '~\\Projects\\codedash'); +}); + +test('shortenHomePath does not shorten sibling prefixes', () => { + assert.equal(shortenHomePath('C:\\Users\\JurijsBaranovs2\\Projects', ['C:\\Users\\JurijsBaranovs']), 'C:\\Users\\JurijsBaranovs2\\Projects'); + assert.equal(shortenHomePath('/home/jurijs2/project', ['/home/jurijs']), '/home/jurijs2/project'); +}); + +test('shortenHomePath shortens Windows paths against WSL-style homes', () => { + const value = 'C:\\Users\\JurijsBaranovs\\Projects\\codedash'; + const homes = ['/mnt/c/Users/JurijsBaranovs']; + assert.equal(shortenHomePath(value, homes), '~\\Projects\\codedash'); +}); + +test('parseWslDistroList strips empty lines and null separators', () => { + const raw = Buffer.from('Ubuntu-24.04\r\ndocker-desktop\r\n\r\n', 'utf16le'); + assert.deepEqual(parseWslDistroList(raw), ['Ubuntu-24.04', 'docker-desktop']); +}); + +test('buildWslUncPath converts linux home to UNC path', () => { + assert.equal(buildWslUncPath('Ubuntu-24.04', '/home/dius'), '\\\\wsl$\\Ubuntu-24.04\\home\\dius'); + assert.equal(buildWslUncPath('', '/home/dius'), ''); +}); + +test('shortenHomePath shortens linux paths against WSL UNC homes', () => { + const value = '/home/dius/projects/codedash'; + const homes = ['\\\\wsl$\\Ubuntu-24.04\\home\\dius']; + assert.equal(shortenHomePath(value, homes), '~/projects/codedash'); +}); + +test('shortenHomePath shortens UNC WSL paths against UNC WSL homes', () => { + const value = '\\\\wsl$\\Ubuntu-24.04\\home\\dius\\projects\\codedash'; + const homes = ['\\\\wsl$\\Ubuntu-24.04\\home\\dius']; + assert.equal(shortenHomePath(value, homes), '~\\projects\\codedash'); +}); + +test('shortenHomePath shortens root-based linux paths against UNC WSL homes', () => { + const value = '/root/projects/codedash'; + const homes = ['\\\\wsl$\\Ubuntu-24.04\\root']; + assert.equal(shortenHomePath(value, homes), '~/projects/codedash'); +}); + +test('mergeClaudeSessionDetail normalizes and shortens project paths', () => { + const session = { + tool: 'claude', + project: '', + project_short: '', + }; + const summary = { + tool: 'claude', + fileSize: 123, + msgCount: 4, + userMsgCount: 2, + mcpServers: ['graph'], + skills: ['review'], + projectPath: '\\\\?\\C:\\Users\\JurijsBaranovs\\Projects\\codedash', + worktreeOriginalCwd: '\\\\?\\C:\\Users\\JurijsBaranovs\\Projects\\codedash', + customTitle: '', + }; + + mergeClaudeSessionDetail(session, summary, 'session.jsonl', ['C:\\Users\\JurijsBaranovs']); + assert.equal(session.project, 'C:\\Users\\JurijsBaranovs\\Projects\\codedash'); + assert.equal(session.project_short, '~\\Projects\\codedash'); + assert.equal(session.worktree_original_cwd, 'C:\\Users\\JurijsBaranovs\\Projects\\codedash'); +}); From 603da02edc438cd8f02cfc8ec75c7bfd9709c54a Mon Sep 17 00:00:00 2001 From: dum3r <131576509+dum3r@users.noreply.github.com> Date: Sun, 12 Apr 2026 13:37:06 +0300 Subject: [PATCH 2/2] feat: prepare narrow WSL ingestion support on latest main --- src/data.js | 107 +++++++---- test/data.test.js | 389 --------------------------------------- test/wsl-windows.test.js | 89 +++++++++ 3 files changed, 162 insertions(+), 423 deletions(-) delete mode 100644 test/data.test.js create mode 100644 test/wsl-windows.test.js diff --git a/src/data.js b/src/data.js index fed0666..7bf691c 100644 --- a/src/data.js +++ b/src/data.js @@ -19,47 +19,84 @@ function normalizeProjectPath(value) { function parseWslDistroList(raw) { const text = Buffer.isBuffer(raw) - ? raw.toString('utf16le') - : String(raw || '').replace(/\0/g, ''); + ? raw.toString('utf16le').replace(/^\uFEFF/, '') + : String(raw || '').replace(/\0/g, '').replace(/^\uFEFF/, ''); return text .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); } -function buildWslUncPath(distro, linuxPath) { - if (!distro || !linuxPath || !linuxPath.startsWith('/')) return ''; - return '\\\\wsl$\\' + distro + linuxPath.replace(/\//g, '\\'); +function getWslDistroList(execFileSyncImpl = execFileSync) { + try { + return parseWslDistroList(execFileSyncImpl('wsl.exe', ['-l', '-q'], { + encoding: 'buffer', + timeout: 3000, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + })); + } catch { + return []; + } } -function detectWindowsWslHomes() { - if (process.platform !== 'win32') return []; - const homes = []; +function getRunningWslDistroSet(execFileSyncImpl = execFileSync) { try { - const distros = parseWslDistroList(execFileSync('wsl.exe', ['-l', '-q'], { + return new Set(parseWslDistroList(execFileSyncImpl('wsl.exe', ['--list', '--quiet', '--running'], { encoding: 'buffer', timeout: 3000, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], - })); + }))); + } catch { + return null; + } +} - for (const distro of distros) { - try { - const linuxHome = String(execFileSync('wsl.exe', ['-d', distro, 'sh', '-lc', 'printf %s "$HOME"'], { - encoding: 'utf8', - timeout: 3000, - windowsHide: true, - stdio: ['pipe', 'pipe', 'pipe'], - }) || '').trim(); - if (!linuxHome || !linuxHome.startsWith('/')) continue; - const uncHome = buildWslUncPath(distro, linuxHome); - if (!uncHome) continue; - const hasClaude = fs.existsSync(path.join(uncHome, '.claude')); - const hasCodex = fs.existsSync(path.join(uncHome, '.codex')); - if (hasClaude || hasCodex) homes.push(uncHome); - } catch {} - } - } catch {} +function filterWslDistrosForProcessScan(distros, runningDistros) { + if (!Array.isArray(distros) || distros.length === 0) return []; + if (!(runningDistros instanceof Set)) return []; + return distros.filter((distro) => runningDistros.has(distro)); +} + +function buildWslUncPath(distro, linuxPath) { + if (!distro || !linuxPath || !linuxPath.startsWith('/')) return ''; + return '\\\\wsl$\\' + distro + linuxPath.replace(/\//g, '\\'); +} + +function detectWindowsWslHomes({ + platform = process.platform, + execFileSyncImpl = execFileSync, + fsImpl = fs, + getDistroList = getWslDistroList, + getRunningDistroSet = getRunningWslDistroSet, +} = {}) { + if (platform !== 'win32') return []; + const homes = []; + const distros = filterWslDistrosForProcessScan( + getDistroList(execFileSyncImpl), + getRunningDistroSet(execFileSyncImpl) + ); + for (const distro of distros) { + try { + const linuxHome = String(execFileSyncImpl('wsl.exe', ['-d', distro, 'sh', '-c', 'printf %s "$HOME"'], { + encoding: 'utf8', + timeout: 3000, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + }) || '').trim(); + if (!linuxHome || !linuxHome.startsWith('/')) continue; + const uncHome = buildWslUncPath(distro, linuxHome); + if (!uncHome) continue; + const hasRelevantAgentData = [ + path.join(uncHome, '.claude'), + path.join(uncHome, '.codex'), + path.join(uncHome, '.cursor'), + path.join(uncHome, '.local', 'share', 'opencode'), + ].some((candidate) => fsImpl.existsSync(candidate)); + if (hasRelevantAgentData) homes.push(uncHome); + } catch {} + } return homes; } @@ -92,7 +129,11 @@ function detectHomes() { try { for (const u of fs.readdirSync('/mnt/c/Users')) { const candidate = '/mnt/c/Users/' + u; - if (fs.existsSync(path.join(candidate, '.claude'))) addHome(candidate); + if ( + fs.existsSync(path.join(candidate, '.claude')) || + fs.existsSync(path.join(candidate, '.codex')) || + fs.existsSync(path.join(candidate, '.cursor')) + ) addHome(candidate); } } catch {} } @@ -3235,14 +3276,12 @@ module.exports = { PROJECTS_DIR, __test: { parseWslDistroList, + getWslDistroList, + getRunningWslDistroSet, + filterWslDistrosForProcessScan, buildWslUncPath, normalizeProjectPath, shortenHomePath, - mergeClaudeSessionDetail, - getCodexTokenCountUsage, - computeCodexCostFromJsonlLines, - mergeCodexSession, - parseSessionIdFromCommandLine, - parseWindowsCwdFromCommandLine, + detectWindowsWslHomes, }, }; diff --git a/test/data.test.js b/test/data.test.js deleted file mode 100644 index ed80052..0000000 --- a/test/data.test.js +++ /dev/null @@ -1,389 +0,0 @@ -const test = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const data = require('../src/data'); - -const { - parseWslDistroList, - buildWslUncPath, - normalizeProjectPath, - shortenHomePath, - mergeClaudeSessionDetail, - computeCodexCostFromJsonlLines, - mergeCodexSession, - parseSessionIdFromCommandLine, - parseWindowsCwdFromCommandLine, -} = data.__test; - -function readFixtureLines(name) { - return fs.readFileSync(path.join(__dirname, 'fixtures', name), 'utf8').split(/\r?\n/).filter(Boolean); -} - -function tokenCountLine({ - timestamp = '2026-03-11T09:13:22.441Z', - last = {}, - total = null, - contextWindow = 258400, -}) { - return JSON.stringify({ - timestamp, - type: 'event_msg', - payload: { - type: 'token_count', - info: total === null && last === null ? null : { - total_token_usage: total, - last_token_usage: last, - model_context_window: contextWindow, - }, - rate_limits: null, - }, - }); -} - -test('computeCodexCostFromJsonlLines uses real token_count usage', () => { - const line = tokenCountLine({ - last: { - input_tokens: 1000, - cached_input_tokens: 400, - output_tokens: 200, - reasoning_output_tokens: 50, - }, - total: { - input_tokens: 1000, - cached_input_tokens: 400, - output_tokens: 200, - reasoning_output_tokens: 50, - }, - }); - - const result = computeCodexCostFromJsonlLines([line], 0); - assert.equal(result.estimated, false); - assert.equal(result.model, 'codex-mini-latest'); - assert.equal(result.inputTokens, 600); - assert.equal(result.cacheReadTokens, 400); - assert.equal(result.outputTokens, 250); - assert.equal(result.contextTurnCount, 1); - assert.ok(result.cost > 0); -}); - -test('computeCodexCostFromJsonlLines parses real new-format Codex fixture', () => { - const lines = readFixtureLines('codex-new-format.jsonl'); - const result = computeCodexCostFromJsonlLines(lines, 0); - assert.equal(result.estimated, false); - assert.equal(result.model, 'codex-mini-latest'); - assert.equal(result.inputTokens, 33102); - assert.equal(result.cacheReadTokens, 38784); - assert.equal(result.outputTokens, 1727); - assert.equal(result.contextTurnCount, 2); - assert.ok(result.contextPctSum > 27 && result.contextPctSum < 28); -}); - -test('computeCodexCostFromJsonlLines dedupes exact duplicate token_count events', () => { - const line = tokenCountLine({ - last: { - input_tokens: 900, - cached_input_tokens: 300, - output_tokens: 100, - reasoning_output_tokens: 25, - }, - total: { - input_tokens: 900, - cached_input_tokens: 300, - output_tokens: 100, - reasoning_output_tokens: 25, - }, - }); - - const once = computeCodexCostFromJsonlLines([line], 0); - const duped = computeCodexCostFromJsonlLines([line, line], 0); - assert.deepEqual(duped, once); -}); - -test('computeCodexCostFromJsonlLines counts distinct events with same last usage but different totals', () => { - const first = tokenCountLine({ - timestamp: '2026-03-11T09:13:22.441Z', - last: { - input_tokens: 500, - cached_input_tokens: 100, - output_tokens: 80, - reasoning_output_tokens: 20, - }, - total: { - input_tokens: 1000, - cached_input_tokens: 500, - output_tokens: 80, - reasoning_output_tokens: 20, - }, - }); - const second = tokenCountLine({ - timestamp: '2026-03-11T09:13:22.441Z', - last: { - input_tokens: 500, - cached_input_tokens: 100, - output_tokens: 80, - reasoning_output_tokens: 20, - }, - total: { - input_tokens: 1500, - cached_input_tokens: 600, - output_tokens: 160, - reasoning_output_tokens: 40, - }, - }); - - const result = computeCodexCostFromJsonlLines([first, second], 0); - assert.equal(result.inputTokens, 800); - assert.equal(result.cacheReadTokens, 200); - assert.equal(result.outputTokens, 200); - assert.equal(result.contextTurnCount, 2); -}); - -test('computeCodexCostFromJsonlLines does not dedupe same-timestamp events when total usage is absent', () => { - const first = tokenCountLine({ - timestamp: '2026-03-11T09:13:22.441Z', - last: { - input_tokens: 500, - cached_input_tokens: 100, - output_tokens: 80, - reasoning_output_tokens: 20, - }, - total: null, - }); - const second = tokenCountLine({ - timestamp: '2026-03-11T09:13:22.441Z', - last: { - input_tokens: 500, - cached_input_tokens: 100, - output_tokens: 80, - reasoning_output_tokens: 20, - }, - total: null, - }); - - const result = computeCodexCostFromJsonlLines([first, second], 0); - assert.equal(result.inputTokens, 800); - assert.equal(result.cacheReadTokens, 200); - assert.equal(result.outputTokens, 200); - assert.equal(result.contextTurnCount, 2); -}); - -test('computeCodexCostFromJsonlLines dedupes interleaved duplicate signatures only once', () => { - const duplicate = tokenCountLine({ - timestamp: '2026-03-11T09:13:22.441Z', - last: { - input_tokens: 700, - cached_input_tokens: 200, - output_tokens: 60, - reasoning_output_tokens: 10, - }, - total: { - input_tokens: 1700, - cached_input_tokens: 700, - output_tokens: 60, - reasoning_output_tokens: 10, - }, - }); - const distinct = tokenCountLine({ - timestamp: '2026-03-11T09:13:23.111Z', - last: { - input_tokens: 900, - cached_input_tokens: 400, - output_tokens: 50, - reasoning_output_tokens: 0, - }, - total: { - input_tokens: 2600, - cached_input_tokens: 1100, - output_tokens: 110, - reasoning_output_tokens: 10, - }, - }); - - const result = computeCodexCostFromJsonlLines([duplicate, distinct, duplicate, duplicate], 0); - assert.equal(result.inputTokens, 1000); - assert.equal(result.cacheReadTokens, 600); - assert.equal(result.outputTokens, 120); - assert.equal(result.contextTurnCount, 2); -}); - -test('computeCodexCostFromJsonlLines falls back to estimate when token_count is absent', () => { - const result = computeCodexCostFromJsonlLines([], 400); - assert.equal(result.estimated, true); - assert.equal(result.model, 'codex-mini-latest-estimated'); - assert.equal(result.inputTokens, 30); - assert.equal(result.outputTokens, 70); - assert.ok(result.cost > 0); -}); - -test('computeCodexCostFromJsonlLines falls back on real old-format Codex fixture without token_count', () => { - const lines = readFixtureLines('codex-old-format.jsonl'); - const totalSize = fs.statSync(path.join(__dirname, 'fixtures', 'codex-old-format.jsonl')).size; - const result = computeCodexCostFromJsonlLines(lines, totalSize); - assert.equal(result.estimated, true); - assert.equal(result.model, 'codex-mini-latest-estimated'); - assert.ok(result.cost > 0); -}); - -test('computeCodexCostFromJsonlLines clamps cached_input_tokens above input_tokens', () => { - const line = tokenCountLine({ - last: { - input_tokens: 100, - cached_input_tokens: 150, - output_tokens: 10, - reasoning_output_tokens: 5, - }, - total: { - input_tokens: 100, - cached_input_tokens: 150, - output_tokens: 10, - reasoning_output_tokens: 5, - }, - }); - - const result = computeCodexCostFromJsonlLines([line], 0); - assert.equal(result.inputTokens, 0); - assert.equal(result.cacheReadTokens, 150); - assert.equal(result.outputTokens, 15); -}); - -test('mergeCodexSession keeps primary precedence when rank is 0', () => { - const existing = { - id: 'same', - project: 'C:\\primary', - first_message: 'primary', - _session_file: 'primary.jsonl', - _codex_root: 'primary-root', - codex_source: 'primary', - _codex_source_rank: 0, - mcp_servers: ['a'], - skills: ['one'], - }; - const candidate = { - id: 'same', - project: 'C:\\archive', - first_message: 'archive', - _session_file: 'archive.jsonl', - _codex_root: 'archive-root', - codex_source: 'archive', - _codex_source_rank: 1, - mcp_servers: ['b'], - skills: ['two'], - }; - - const merged = mergeCodexSession(existing, candidate); - assert.equal(merged.project, 'C:\\primary'); - assert.equal(merged.first_message, 'primary'); - assert.equal(merged._session_file, 'primary.jsonl'); - assert.equal(merged.codex_source, 'primary'); - assert.equal(merged._codex_source_rank, 0); - assert.deepEqual(merged.mcp_servers.sort(), ['a', 'b']); - assert.deepEqual(merged.skills.sort(), ['one', 'two']); -}); - -test('mergeCodexSession does not replace existing session file with empty candidate file', () => { - const existing = { - _session_file: 'primary.jsonl', - _codex_source_rank: 1, - project: '', - project_short: '', - first_message: '', - }; - const candidate = { - _session_file: '', - _codex_source_rank: 0, - project: 'C:\\primary', - project_short: 'C:\\primary', - first_message: 'newer', - }; - - const merged = mergeCodexSession(existing, candidate); - assert.equal(merged._session_file, 'primary.jsonl'); - assert.equal(merged.project, 'C:\\primary'); - assert.equal(merged.first_message, 'newer'); -}); - -test('parseSessionIdFromCommandLine extracts resume UUID', () => { - const cmd = 'cmd /k "cd C:\\1_Projects && codex resume 019d6dc8-03d4-72e0-8239-bda72acb65fb"'; - assert.equal(parseSessionIdFromCommandLine(cmd), '019d6dc8-03d4-72e0-8239-bda72acb65fb'); -}); - -test('parseWindowsCwdFromCommandLine extracts cwd from cmd wrapper', () => { - const cmd = 'cmd /k "cd C:\\1_Projects\\codedash && codex resume 019d6dc8-03d4-72e0-8239-bda72acb65fb"'; - assert.equal(parseWindowsCwdFromCommandLine(cmd), 'C:\\1_Projects\\codedash'); -}); - -test('normalizeProjectPath strips Windows extended-length prefixes', () => { - assert.equal(normalizeProjectPath('\\\\?\\C:\\1_Projects\\codedash'), 'C:\\1_Projects\\codedash'); - assert.equal(normalizeProjectPath('\\\\?\\UNC\\server\\share\\repo'), '\\\\server\\share\\repo'); -}); - -test('shortenHomePath matches normalized home roots', () => { - const value = '\\\\?\\C:\\Users\\JurijsBaranovs\\Projects\\codedash'; - const homes = ['C:\\Users\\JurijsBaranovs']; - assert.equal(shortenHomePath(value, homes), '~\\Projects\\codedash'); -}); - -test('shortenHomePath does not shorten sibling prefixes', () => { - assert.equal(shortenHomePath('C:\\Users\\JurijsBaranovs2\\Projects', ['C:\\Users\\JurijsBaranovs']), 'C:\\Users\\JurijsBaranovs2\\Projects'); - assert.equal(shortenHomePath('/home/jurijs2/project', ['/home/jurijs']), '/home/jurijs2/project'); -}); - -test('shortenHomePath shortens Windows paths against WSL-style homes', () => { - const value = 'C:\\Users\\JurijsBaranovs\\Projects\\codedash'; - const homes = ['/mnt/c/Users/JurijsBaranovs']; - assert.equal(shortenHomePath(value, homes), '~\\Projects\\codedash'); -}); - -test('parseWslDistroList strips empty lines and null separators', () => { - const raw = Buffer.from('Ubuntu-24.04\r\ndocker-desktop\r\n\r\n', 'utf16le'); - assert.deepEqual(parseWslDistroList(raw), ['Ubuntu-24.04', 'docker-desktop']); -}); - -test('buildWslUncPath converts linux home to UNC path', () => { - assert.equal(buildWslUncPath('Ubuntu-24.04', '/home/dius'), '\\\\wsl$\\Ubuntu-24.04\\home\\dius'); - assert.equal(buildWslUncPath('', '/home/dius'), ''); -}); - -test('shortenHomePath shortens linux paths against WSL UNC homes', () => { - const value = '/home/dius/projects/codedash'; - const homes = ['\\\\wsl$\\Ubuntu-24.04\\home\\dius']; - assert.equal(shortenHomePath(value, homes), '~/projects/codedash'); -}); - -test('shortenHomePath shortens UNC WSL paths against UNC WSL homes', () => { - const value = '\\\\wsl$\\Ubuntu-24.04\\home\\dius\\projects\\codedash'; - const homes = ['\\\\wsl$\\Ubuntu-24.04\\home\\dius']; - assert.equal(shortenHomePath(value, homes), '~\\projects\\codedash'); -}); - -test('shortenHomePath shortens root-based linux paths against UNC WSL homes', () => { - const value = '/root/projects/codedash'; - const homes = ['\\\\wsl$\\Ubuntu-24.04\\root']; - assert.equal(shortenHomePath(value, homes), '~/projects/codedash'); -}); - -test('mergeClaudeSessionDetail normalizes and shortens project paths', () => { - const session = { - tool: 'claude', - project: '', - project_short: '', - }; - const summary = { - tool: 'claude', - fileSize: 123, - msgCount: 4, - userMsgCount: 2, - mcpServers: ['graph'], - skills: ['review'], - projectPath: '\\\\?\\C:\\Users\\JurijsBaranovs\\Projects\\codedash', - worktreeOriginalCwd: '\\\\?\\C:\\Users\\JurijsBaranovs\\Projects\\codedash', - customTitle: '', - }; - - mergeClaudeSessionDetail(session, summary, 'session.jsonl', ['C:\\Users\\JurijsBaranovs']); - assert.equal(session.project, 'C:\\Users\\JurijsBaranovs\\Projects\\codedash'); - assert.equal(session.project_short, '~\\Projects\\codedash'); - assert.equal(session.worktree_original_cwd, 'C:\\Users\\JurijsBaranovs\\Projects\\codedash'); -}); diff --git a/test/wsl-windows.test.js b/test/wsl-windows.test.js new file mode 100644 index 0000000..97393ac --- /dev/null +++ b/test/wsl-windows.test.js @@ -0,0 +1,89 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const data = require('../src/data'); + +const { + parseWslDistroList, + getWslDistroList, + getRunningWslDistroSet, + filterWslDistrosForProcessScan, + buildWslUncPath, + normalizeProjectPath, + shortenHomePath, + detectWindowsWslHomes, +} = data.__test; + +test('parseWslDistroList decodes UTF-16LE output and strips BOM/nulls', () => { + const raw = Buffer.from('\uFEFFUbuntu-24.04\r\ndocker-desktop\r\n', 'utf16le'); + assert.deepEqual(parseWslDistroList(raw), ['Ubuntu-24.04', 'docker-desktop']); + assert.deepEqual(parseWslDistroList('\uFEFFUbuntu\0-24.04\r\ndocker\0-desktop\r\n'), ['Ubuntu-24.04', 'docker-desktop']); +}); + +test('WSL list helpers fail safely when WSL is unavailable', () => { + assert.deepEqual(getWslDistroList(() => { throw new Error('ENOENT'); }), []); + assert.equal(getRunningWslDistroSet(() => { throw new Error('disabled'); }), null); +}); + +test('running distro filter keeps only running distros when available', () => { + const running = getRunningWslDistroSet(() => Buffer.from('\uFEFFUbuntu-24.04\r\ndebian\r\n', 'utf16le')); + assert.deepEqual(Array.from(running).sort(), ['Ubuntu-24.04', 'debian']); + assert.deepEqual( + filterWslDistrosForProcessScan(['Ubuntu-24.04', 'docker-desktop', 'debian'], running), + ['Ubuntu-24.04', 'debian'] + ); + assert.deepEqual(filterWslDistrosForProcessScan(['Ubuntu-24.04'], null), []); +}); + +test('buildWslUncPath and shortenHomePath normalize WSL-visible paths', () => { + assert.equal(buildWslUncPath('Ubuntu-24.04', '/home/dius'), '\\\\wsl$\\Ubuntu-24.04\\home\\dius'); + assert.equal(buildWslUncPath('Ubuntu-24.04', '/'), '\\\\wsl$\\Ubuntu-24.04\\'); + assert.equal(buildWslUncPath('', '/home/dius'), ''); + assert.equal(buildWslUncPath('Ubuntu-24.04', 'home/dius'), ''); + assert.equal(shortenHomePath('/home/dius/projects/codedash', ['\\\\wsl$\\Ubuntu-24.04\\home\\dius']), '~/projects/codedash'); + assert.equal(shortenHomePath('\\\\wsl$\\Ubuntu-24.04\\home\\dius\\projects\\codedash', ['\\\\wsl$\\Ubuntu-24.04\\home\\dius']), '~\\projects\\codedash'); + assert.equal(normalizeProjectPath('\\\\?\\C:\\Projects\\codedash'), 'C:\\Projects\\codedash'); +}); + +test('detectWindowsWslHomes discovers only running distros with supported agent data', () => { + const existing = new Set([ + '\\\\wsl$\\Ubuntu-24.04\\home\\dius\\.codex', + '\\\\wsl$\\Debian\\home\\tester\\.cursor', + ]); + const mockFs = { existsSync: (candidate) => existing.has(candidate) }; + const distros = ['Ubuntu-24.04', 'Debian', 'Stopped']; + const running = new Set(['Ubuntu-24.04', 'Debian']); + const homeByDistro = { + 'Ubuntu-24.04': '/home/dius', + Debian: '/home/tester', + Stopped: '/home/stopped', + }; + const execFileSyncImpl = (_exe, args) => { + const distro = args[1]; + return homeByDistro[distro]; + }; + + const homes = detectWindowsWslHomes({ + platform: 'win32', + execFileSyncImpl, + fsImpl: mockFs, + getDistroList: () => distros, + getRunningDistroSet: () => running, + }); + + assert.deepEqual(homes, [ + '\\\\wsl$\\Ubuntu-24.04\\home\\dius', + '\\\\wsl$\\Debian\\home\\tester', + ]); +}); + +test('detectWindowsWslHomes returns empty on non-Windows and does not throw for empty distro lists', () => { + assert.deepEqual(detectWindowsWslHomes({ platform: 'linux' }), []); + assert.deepEqual(detectWindowsWslHomes({ + platform: 'win32', + execFileSyncImpl: () => { throw new Error('should not execute'); }, + fsImpl: { existsSync: () => false }, + getDistroList: () => [], + getRunningDistroSet: () => new Set(), + }), []); +});