From fb6f7454f7c9e2941fe2bae2c49eb4fe94887961 Mon Sep 17 00:00:00 2001 From: Yuri Baburov Date: Fri, 10 Apr 2026 19:47:50 +0300 Subject: [PATCH] feat: add Kimi CLI support with project path resolution --- README.md | 3 + docs/ARCHITECTURE.md | 38 +++- docs/README_RU.md | 5 +- docs/README_ZH.md | 5 +- src/data.js | 353 +++++++++++++++++++++++++++++++++++++- src/frontend/analytics.js | 4 +- src/frontend/app.js | 35 +++- src/frontend/calendar.js | 3 + src/frontend/heatmap.js | 2 +- src/frontend/index.html | 8 + src/frontend/styles.css | 7 + src/terminals.js | 3 + 12 files changed, 435 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 2956888..a68e8a5 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ codedash run | Cursor | JSONL | Yes | Yes | Yes | - | Yes | Open in Cursor | | OpenCode | SQLite | Yes | Yes | Yes | - | Yes | Terminal | | Kiro CLI | SQLite | Yes | Yes | Yes | - | Yes | Terminal | +| Kimi CLI | JSONL | Yes | Yes | Yes | - | Yes | Terminal | Also detects Claude Code running inside Cursor (via `claude-vscode` entrypoint). @@ -82,6 +83,7 @@ codedash stop ~/.cursor/projects/*/agent-transcripts/ Cursor agent sessions ~/.local/share/opencode/opencode.db OpenCode (SQLite) ~/Library/Application Support/kiro-cli/ Kiro CLI (SQLite) +~/.kimi/sessions/ Kimi CLI sessions ``` Zero dependencies. Everything runs on `localhost`. @@ -93,6 +95,7 @@ curl -fsSL https://claude.ai/install.sh | bash # Claude Code npm i -g @openai/codex # Codex CLI curl -fsSL https://cli.kiro.dev/install | bash # Kiro CLI curl -fsSL https://opencode.ai/install | bash # OpenCode +pip install kimi-cli # Kimi CLI ``` ## Requirements diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1343794..1f0ff62 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,7 +2,7 @@ ## Overview -CodeDash is a zero-dependency Node.js dashboard for AI coding agent sessions. Supports 6 agents: Claude Code, Claude Extension, Codex, Cursor, OpenCode, Kiro. Single process serves a web UI at `localhost:3847`. +CodeDash is a zero-dependency Node.js dashboard for AI coding agent sessions. Supports 7 agents: Claude Code, Claude Extension, Codex, Cursor, OpenCode, Kiro, Kimi. Single process serves a web UI at `localhost:3847`. ``` Browser (localhost:3847) Node.js Server @@ -21,10 +21,11 @@ Browser (localhost:3847) Node.js Server | convert/export/ | | +-- changelog.js | | import/update | +-------------------------------+ +-------------------+ | - reads from 5 locations: + reads from 6 locations: ~/.claude/ ~/.codex/ ~/.cursor/ ~/.local/share/opencode/opencode.db ~/Library/Application Support/kiro-cli/data.sqlite3 + ~/.kimi/sessions/ ``` ## Project Structure @@ -197,6 +198,30 @@ FROM conversations_v2 ORDER BY updated_at DESC } ``` +### 7. Kimi CLI + +| Item | Location | +|------|----------| +| Session data | `~/.kimi/sessions///context.jsonl` | +| Metadata | `~/.kimi/sessions///state.json` | + +**PROJECT_HASH**: MD5 hash of the project directory path. + +**context.jsonl** — one JSON object per line: +```json +{"role": "user", "content": "fix the bug"} +{"role": "assistant", "content": [{"type": "think", "think": "..."}, {"type": "text", "text": "I'll fix..."}]} +``` + +**state.json** — session metadata: +```json +{ + "custom_title": "Session title", + "archived": false, + "plan_mode": false +} +``` + --- ## Data Flow @@ -209,11 +234,12 @@ FROM conversations_v2 ORDER BY updated_at DESC 3. scanOpenCodeSessions() → merge (tool: "opencode") 4. scanCursorSessions() → merge (tool: "cursor") 5. scanKiroSessions() → merge (tool: "kiro") -6. Enrich Claude sessions with detail files: +6. scanKimiSessions() → merge (tool: "kimi") +7. Enrich Claude sessions with detail files: - Count messages, get file size - Check entrypoint → change tool to "claude-ext" if not "cli" -7. Scan orphan sessions from ~/.claude/projects/ (Claude Extension) -8. Sort by last_ts DESC, format dates +8. Scan orphan sessions from ~/.claude/projects/ (Claude Extension) +9. Sort by last_ts DESC, format dates ``` ### Search Index @@ -240,7 +266,7 @@ Codex fallback: estimate from file size (~4 bytes per token). ``` 1. Read ~/.claude/sessions/*.json → PID-to-session map -2. ps aux | grep "claude|codex|opencode|kiro-cli|cursor-agent" +2. ps aux | grep "claude|codex|opencode|kiro-cli|kimi|cursor-agent" 3. For each process: parse PID, CPU%, memory, state 4. Status: "active" (CPU >= 1%) or "waiting" (sleeping/stopped) 5. Map PID → sessionId via PID files diff --git a/docs/README_RU.md b/docs/README_RU.md index ba383f5..d46a073 100644 --- a/docs/README_RU.md +++ b/docs/README_RU.md @@ -1,6 +1,6 @@ # CodeDash -Дашборд + CLI для сессий AI-агентов. 5 агентов: Claude Code, Codex, Cursor, OpenCode, Kiro. +Дашборд + CLI для сессий AI-агентов. 6 агентов: Claude Code, Codex, Cursor, OpenCode, Kiro, Kimi. [English](../README.md) | [Chinese / 中文](README_ZH.md) @@ -19,12 +19,13 @@ npm i -g codedash-app && codedash run | Cursor | JSONL | LIVE/WAITING | - | Open in Cursor | | OpenCode | SQLite | LIVE/WAITING | - | Терминал | | Kiro CLI | SQLite | LIVE/WAITING | - | Терминал | +| Kimi CLI | JSONL | LIVE/WAITING | - | Терминал | ## Возможности - Grid/List, группировка по проектам, trigram поиск + deep search - GitHub-стиль SVG heatmap активности со стриками -- LIVE/WAITING бейджи для всех 5 агентов, анимированная рамка +- LIVE/WAITING бейджи для всех 6 агентов, анимированная рамка - Session Replay с ползунком, hover превью, раскрытие карточек - Аналитика стоимости из реальных usage данных - Конвертация сессий Claude <-> Codex, Handoff между агентами diff --git a/docs/README_ZH.md b/docs/README_ZH.md index 8c66673..68fef92 100644 --- a/docs/README_ZH.md +++ b/docs/README_ZH.md @@ -1,6 +1,6 @@ # CodeDash -AI 编程代理会话仪表板 + CLI。支持 5 个代理:Claude Code、Codex、Cursor、OpenCode、Kiro。 +AI 编程代理会话仪表板 + CLI。支持 6 个代理:Claude Code、Codex、Cursor、OpenCode、Kiro、Kimi。 [English](../README.md) | [Russian / Русский](README_RU.md) @@ -19,12 +19,13 @@ npm i -g codedash-app && codedash run | Cursor | JSONL | LIVE/WAITING | - | 在 Cursor 中打开 | | OpenCode | SQLite | LIVE/WAITING | - | 终端 | | Kiro CLI | SQLite | LIVE/WAITING | - | 终端 | +| Kimi CLI | JSONL | LIVE/WAITING | - | 终端 | ## 功能 - 网格/列表视图、项目分组、Trigram 搜索 + 深度搜索 - GitHub 风格 SVG 活动热力图 -- 所有 5 个代理的 LIVE/WAITING 徽章 +- 所有 6 个代理的 LIVE/WAITING 徽章 - 会话回放、成本分析、跨代理转换和交接 - 导出/导入迁移、Dark/Light/System 主题 diff --git a/src/data.js b/src/data.js index 557d875..2038fc4 100644 --- a/src/data.js +++ b/src/data.js @@ -1,6 +1,7 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); +const crypto = require('crypto'); const { execSync, execFileSync } = require('child_process'); // ── Constants ────────────────────────────────────────────── @@ -42,6 +43,7 @@ const CLAUDE_DIR = path.join(ALL_HOMES[0], '.claude'); const CODEX_DIR = path.join(ALL_HOMES[0], '.codex'); const OPENCODE_DB = path.join(ALL_HOMES[0], '.local', 'share', 'opencode', 'opencode.db'); const KIRO_DB = path.join(ALL_HOMES[0], 'Library', 'Application Support', 'kiro-cli', 'data.sqlite3'); +const KIMI_DIR = path.join(ALL_HOMES[0], '.kimi', 'sessions'); const CURSOR_DIR = path.join(ALL_HOMES[0], '.cursor'); const CURSOR_PROJECTS = path.join(CURSOR_DIR, 'projects'); const CURSOR_CHATS = path.join(CURSOR_DIR, 'chats'); @@ -64,6 +66,10 @@ const EXTRA_CURSOR_DIRS = ALL_HOMES.slice(1).map(h => path.join(h, '.cursor')).f // Extra OpenCode/Kiro DBs on Windows side const EXTRA_OPENCODE_DBS = ALL_HOMES.slice(1).map(h => path.join(h, 'AppData', 'Local', 'opencode', 'opencode.db')).filter(d => fs.existsSync(d)); const EXTRA_KIRO_DBS = ALL_HOMES.slice(1).map(h => path.join(h, 'AppData', 'Roaming', 'kiro-cli', 'data.sqlite3')).filter(d => fs.existsSync(d)); +const EXTRA_KIMI_DIRS = ALL_HOMES.slice(1).map(h => path.join(h, '.kimi', 'sessions')).filter(d => fs.existsSync(d)); + +// Kimi project hash -> actual project path mapping cache (built lazily) +let _kimiProjectPathCache = {}; if (IS_WSL) { console.log(' \x1b[36m[WSL]\x1b[0m Detected Windows homes:', ALL_HOMES.slice(1).join(', ')); @@ -583,6 +589,247 @@ function loadKiroDetail(conversationId) { } } +// ── Kimi ──────────────────────────────────────────────────── +// Kimi stores sessions in ~/.kimi/sessions/// +// Each session has: state.json (metadata), context.jsonl (messages), wire.jsonl (protocol) + +function scanKimiSessions() { + const sessions = []; + if (!fs.existsSync(KIMI_DIR)) return sessions; + + try { + for (const projectHash of fs.readdirSync(KIMI_DIR)) { + const projectDir = path.join(KIMI_DIR, projectHash); + if (!fs.statSync(projectDir).isDirectory()) continue; + + for (const sessionId of fs.readdirSync(projectDir)) { + const sessionDir = path.join(projectDir, sessionId); + if (!fs.statSync(sessionDir).isDirectory()) continue; + + const contextFile = path.join(sessionDir, 'context.jsonl'); + const stateFile = path.join(sessionDir, 'state.json'); + + if (!fs.existsSync(contextFile)) continue; + + const stat = fs.statSync(contextFile); + let firstMsg = ''; + let msgCount = 0; + let customTitle = ''; + let firstTs = stat.mtimeMs; + let lastTs = stat.mtimeMs; + + // Try to read title from state.json + try { + const state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + if (state.custom_title) customTitle = state.custom_title; + else if (state.title) customTitle = state.title; + } catch {} + + // Parse context.jsonl for message count and first message + try { + const lines = readLines(contextFile); + for (const line of lines) { + try { + const entry = JSON.parse(line); + if (entry.role === 'user' || entry.role === 'assistant') { + msgCount++; + if (!firstMsg && entry.content) { + const content = extractContent(entry.content).trim(); + if (content) firstMsg = content.slice(0, 200); + } + } + if (entry.timestamp) { + const ts = new Date(entry.timestamp).getTime(); + if (!isNaN(ts)) { + if (ts < firstTs) firstTs = ts; + if (ts > lastTs) lastTs = ts; + } + } + } catch {} + } + } catch {} + + // If no timestamps found, use file mtimes + if (firstTs === stat.mtimeMs) { + firstTs = stat.mtimeMs - (msgCount * 60000); + } + + // Try to find actual project path for this hash + let projectPath = projectHash; + try { + // Check common project paths + const searchPaths = ['/srv', '/media', '/home', os.homedir(), os.homedir() + '/projects']; + for (const basePath of searchPaths) { + if (!fs.existsSync(basePath)) continue; + for (const entry of fs.readdirSync(basePath, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const fullPath = path.join(basePath, entry.name); + const hash = crypto.createHash('md5').update(fullPath).digest('hex'); + if (hash === projectHash) { + projectPath = fullPath; + _kimiProjectPathCache[hash] = fullPath; + break; + } + } + if (projectPath !== projectHash) break; + } + } catch {} + + sessions.push({ + id: sessionId, + tool: 'kimi', + project: projectPath, // Use actual path if found, otherwise hash + project_short: typeof projectPath === 'string' && projectPath.includes('/') ? + projectPath.split('/').pop() : projectHash.slice(0, 16), + first_ts: firstTs, + last_ts: lastTs, + messages: msgCount, + first_message: customTitle || firstMsg, + has_detail: true, + file_size: stat.size, + detail_messages: msgCount, + _kimi_project_hash: projectHash, + }); + } + } + } catch {} + + return sessions; +} + +// Build a cache mapping Kimi project hash -> actual project path +// This helps display meaningful project names instead of hashes +function buildKimiProjectPathCache() { + if (Object.keys(_kimiProjectPathCache).length > 0) return _kimiProjectPathCache; + + // Common paths where projects might be located + const searchPaths = ['/srv', '/media', '/home', os.homedir(), os.homedir() + '/projects']; + + for (const basePath of searchPaths) { + if (!fs.existsSync(basePath)) continue; + try { + for (const entry of fs.readdirSync(basePath, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const fullPath = path.join(basePath, entry.name); + try { + // Compute hash the same way Kimi does + const hash = crypto.createHash('md5').update(fullPath).digest('hex'); + // Check if this hash exists in Kimi sessions + if (fs.existsSync(path.join(KIMI_DIR, hash)) && !_kimiProjectPathCache[hash]) { + _kimiProjectPathCache[hash] = fullPath; + } + } catch {} + } + } catch {} + } + + return _kimiProjectPathCache; +} + +function getKimiProjectPath(hash) { + const cache = buildKimiProjectPathCache(); + return cache[hash] || null; +} + +function loadKimiDetail(sessionId) { + // Find the session directory + let sessionDir = null; + let projectHash = null; + + if (!fs.existsSync(KIMI_DIR)) return { messages: [] }; + + try { + for (const ph of fs.readdirSync(KIMI_DIR)) { + const candidate = path.join(KIMI_DIR, ph, sessionId); + if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) { + sessionDir = candidate; + projectHash = ph; + break; + } + } + } catch {} + + if (!sessionDir) return { messages: [] }; + + const contextFile = path.join(sessionDir, 'context.jsonl'); + if (!fs.existsSync(contextFile)) return { messages: [] }; + + const messages = []; + + try { + const lines = readLines(contextFile); + for (const line of lines) { + try { + const entry = JSON.parse(line); + if (entry.role !== 'user' && entry.role !== 'assistant') continue; + + const content = extractContent(entry.content); + if (!content || isSystemMessage(content)) continue; + + messages.push({ + role: entry.role, + content: content.slice(0, 2000), + uuid: entry.id || '', + }); + } catch {} + } + } catch {} + + return { messages: messages.slice(0, 200) }; +} + +// Fast check if a Kimi session is waiting for user input +// Uses file mtime to detect recent activity (much faster than parsing) +// Returns: 'waiting' | 'active' | 'unknown' +function getKimiSessionStatus(sessionId) { + if (!fs.existsSync(KIMI_DIR)) return 'unknown'; + + try { + for (const projectHash of fs.readdirSync(KIMI_DIR)) { + const wireFile = path.join(KIMI_DIR, projectHash, sessionId, 'wire.jsonl'); + if (!fs.existsSync(wireFile)) continue; + + // Fast check: if file modified in last 5 seconds, it's active + const stat = fs.statSync(wireFile); + const ageMs = Date.now() - stat.mtimeMs; + if (ageMs < 5000) return 'active'; // Modified recently = working + + // For older files, check last 4KB of file (not entire file) + const fd = fs.openSync(wireFile, 'r'); + const bufferSize = Math.min(4096, stat.size); + const buffer = Buffer.alloc(bufferSize); + fs.readSync(fd, buffer, 0, bufferSize, stat.size - bufferSize); + fs.closeSync(fd); + + const content = buffer.toString('utf8'); + const lines = content.split('\n').filter(Boolean); + + for (let i = lines.length - 1; i >= 0; i--) { + try { + const entry = JSON.parse(lines[i]); + const msgType = entry.message?.type || entry.type; + + // Skip StatusUpdate and metadata + if (msgType === 'StatusUpdate' || msgType === 'metadata') continue; + + // TurnEnd means waiting for user input + if (msgType === 'TurnEnd') return 'waiting'; + + // These mean actively working + if (['ToolCall', 'ToolCallPart', 'ToolResult', 'StepBegin', + 'ContentPart', 'Thinking', 'TurnBegin'].includes(msgType)) { + return 'active'; + } + + return 'unknown'; + } catch {} + } + } + } catch {} + + return 'unknown'; +} + // Cursor stores each workspace under ~/.cursor/projects// where is the // absolute path with / and . replaced by -. Hyphens inside a directory name are // preserved, so splitting on "-" cannot recover the path. Decode by @@ -1471,6 +1718,14 @@ function loadSessions() { } } catch {} + // Load Kimi sessions + try { + const kimiSessions = scanKimiSessions(); + for (const ks of kimiSessions) { + sessions[ks.id] = ks; + } + } catch {} + // WSL: also load from Windows-side dirs for (const extraClaudeDir of EXTRA_CLAUDE_DIRS) { try { @@ -1680,6 +1935,11 @@ function loadSessionDetail(sessionId, project) { return loadKiroDetail(sessionId); } + // Kimi uses JSONL + if (found.format === 'kimi') { + return loadKimiDetail(sessionId); + } + const messages = []; const lines = readLines(found.file); @@ -1826,6 +2086,7 @@ function exportSessionMarkdown(sessionId, project) { found.format === 'cursor' ? loadCursorDetail(sessionId) : found.format === 'opencode' ? loadOpenCodeDetail(sessionId) : found.format === 'kiro' ? loadKiroDetail(sessionId) : + found.format === 'kimi' ? loadKimiDetail(sessionId) : null; if (detail && detail.messages && detail.messages.length > 0) { const parts = [`# Session ${sessionId}\n\n**Project:** ${project || '(none)'}\n`]; @@ -1938,6 +2199,24 @@ function _buildSessionFileIndex() { } catch {} } + // Index Kimi sessions + if (fs.existsSync(KIMI_DIR)) { + try { + for (const projectHash of fs.readdirSync(KIMI_DIR)) { + const projectDir = path.join(KIMI_DIR, projectHash); + try { + if (!fs.statSync(projectDir).isDirectory()) continue; + for (const sessionId of fs.readdirSync(projectDir)) { + const contextFile = path.join(projectDir, sessionId, 'context.jsonl'); + if (fs.existsSync(contextFile)) { + _sessionFileIndex[sessionId] = { file: contextFile, format: 'kimi' }; + } + } + } catch {} + } + } catch {} + } + _sessionFileIndexTs = now; } @@ -2010,6 +2289,18 @@ function findSessionFile(sessionId, project) { } catch {} } + // Try Kimi (JSONL in session directories) + if (fs.existsSync(KIMI_DIR)) { + try { + for (const projectHash of fs.readdirSync(KIMI_DIR)) { + const contextFile = path.join(KIMI_DIR, projectHash, sessionId, 'context.jsonl'); + if (fs.existsSync(contextFile)) { + return { file: contextFile, format: 'kimi', sessionId: sessionId }; + } + } + } catch {} + } + return null; } @@ -2104,6 +2395,14 @@ function getSessionPreview(sessionId, project, limit) { }); } + // Kimi: use loadKimiDetail and slice + if (found.format === 'kimi') { + const detail = loadKimiDetail(sessionId); + return detail.messages.slice(0, limit).map(function(m) { + return { role: m.role, content: m.content.slice(0, 300) }; + }); + } + const messages = []; const lines = readLines(found.file); @@ -2354,7 +2653,7 @@ function computeSessionCost(sessionId, project) { if (!found) { _costMemCache[sessionId] = EMPTY_COST; return EMPTY_COST; } // Skip formats that never have cost data - if (found.format === 'cursor' || found.format === 'kiro') { _costMemCache[sessionId] = EMPTY_COST; return EMPTY_COST; } + if (found.format === 'cursor' || found.format === 'kiro' || found.format === 'kimi') { _costMemCache[sessionId] = EMPTY_COST; return EMPTY_COST; } // Check disk cache (keyed by file path + mtime + size for JSONL, sessionId for SQLite) _loadCostDiskCache(); @@ -2726,6 +3025,10 @@ function _computeCostAnalytics(sessions) { // ── Active sessions detection ───────────────────────────── +// Cache for loadSessions to avoid repeated calls during active session scanning +let _activeSessionsCache = null; +let _activeSessionsCacheTs = 0; + function getActiveSessions() { const active = []; const seenPids = new Set(); @@ -2749,6 +3052,7 @@ function getActiveSessions() { { pattern: 'codex', tool: 'codex', match: /\/codex\s|^codex\s|codex app-server|\bcodex\b/ }, { pattern: 'opencode', tool: 'opencode', match: /\/opencode\s|^opencode\s|\bopencode\b/ }, { pattern: 'kiro', tool: 'kiro', match: /kiro-cli/ }, + { pattern: 'kimi', tool: 'kimi', match: /kimi\s|kimi-cli/i }, { pattern: 'cursor-agent', tool: 'cursor', match: /cursor-agent/ }, ]; @@ -2757,7 +3061,7 @@ function getActiveSessions() { try { const psOut = execSync( - 'ps aux 2>/dev/null | grep -E "claude|codex|opencode|kiro-cli|cursor-agent" | grep -v grep || true', + 'ps aux 2>/dev/null | grep -iE "claude|codex|opencode|kiro-cli|kimi|cursor-agent" | grep -v grep || true', { encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] } ); @@ -2786,6 +3090,8 @@ function getActiveSessions() { if (cmd.includes('/plugins/') || cmd.includes('plugin-') || cmd.includes('app-server-broker')) continue; if (cmd.includes('.claude/') && !cmd.includes('claude ') && tool === 'claude') continue; if (cmd.includes('.codex/') && !cmd.includes('codex ') && tool === 'codex') continue; + // Skip Kimi wrapper scripts (SCREEN, bash wrappers), keep only main "Kimi Code" process + if (tool === 'kimi' && (cmd.includes('SCREEN') || cmd.includes('/bin/bash'))) continue; seenPids.add(pid); @@ -2801,19 +3107,41 @@ function getActiveSessions() { if (sessionId) sessionSource = 'pid-file'; } - // Try to get cwd from lsof if not from PID file + // Try to get cwd from /proc/PID/cwd (much faster than lsof) if (!cwd) { try { - const lsofOut = execSync(`lsof -d cwd -p ${pid} -Fn 2>/dev/null`, { encoding: 'utf8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] }); - const match = lsofOut.match(/\nn(\/[^\n]+)/); - if (match) cwd = match[1]; + cwd = fs.readlinkSync(`/proc/${pid}/cwd`); } catch {} } // Try to find session ID by matching cwd + tool to loaded sessions + // Cache sessions to avoid repeated loadSessions() calls if (!sessionId) { - const allS = loadSessions(); - const match = allS.find(s => s.tool === tool && s.project === cwd); + if (!_activeSessionsCache || (Date.now() - _activeSessionsCacheTs) > 5000) { + _activeSessionsCache = loadSessions(); + _activeSessionsCacheTs = Date.now(); + } + const allS = _activeSessionsCache; + + let match = null; + + // For Kimi, compute project hash from cwd since Kimi uses MD5(project_path) as folder name + // Kimi may store hash of either the symlink path or real path, so try both + if (tool === 'kimi' && cwd) { + try { + const cwdHash = crypto.createHash('md5').update(cwd).digest('hex'); + const realCwd = fs.realpathSync(cwd); + const realHash = crypto.createHash('md5').update(realCwd).digest('hex'); + + // First try direct hash matches + match = allS.find(s => s.tool === tool && (s.project === cwdHash || s.project === realHash)); + } catch {} + } + + // Standard path matching for other tools + if (!match) { + match = allS.find(s => s.tool === tool && s.project === cwd); + } if (match) { sessionId = match.id; sessionSource = 'cwd-match'; @@ -2828,7 +3156,14 @@ function getActiveSessions() { } } - const status = cpu < 1 && (stat.includes('S') || stat.includes('T')) ? 'waiting' : 'active'; + let status; + if (tool === 'kimi' && sessionId) { + // For Kimi, check wire.jsonl to see if waiting for input + const kimiStatus = getKimiSessionStatus(sessionId); + status = kimiStatus === 'waiting' ? 'waiting' : (kimiStatus === 'active' ? 'active' : (cpu < 1 && (stat.includes('S') || stat.includes('T')) ? 'waiting' : 'active')); + } else { + status = cpu < 1 && (stat.includes('S') || stat.includes('T')) ? 'waiting' : 'active'; + } active.push({ pid: pid, diff --git a/src/frontend/analytics.js b/src/frontend/analytics.js index cabf110..8a25626 100644 --- a/src/frontend/analytics.js +++ b/src/frontend/analytics.js @@ -72,7 +72,7 @@ async function renderAnalytics(container) { coverageparts.push(byAgent['opencode'].estimated ? 'OpenCode ~est.' : 'OpenCode \u2713'); - ['cursor', 'kiro'].forEach(function(a) { + ['cursor', 'kiro', 'kimi'].forEach(function(a) { if (noCost[a] > 0) coverageparts.push('' + a + ' \u2717 (no token data)'); }); @@ -222,7 +222,7 @@ async function renderAnalytics(container) { agentEntries.forEach(function(entry) { var name = entry[0]; var info = entry[1]; var pct = maxAgentCost > 0 ? (info.cost / maxAgentCost * 100) : 0; - var label = { 'claude': 'Claude Code', 'claude-ext': 'Claude Ext', 'codex': 'Codex', 'opencode': 'OpenCode', 'cursor': 'Cursor', 'kiro': 'Kiro' }[name] || name; + var label = { 'claude': 'Claude Code', 'claude-ext': 'Claude Ext', 'codex': 'Codex', 'opencode': 'OpenCode', 'cursor': 'Cursor', 'kiro': 'Kiro', 'kimi': 'Kimi' }[name] || name; var estMark = info.estimated ? ' ~est.' : ''; html += '
'; html += '' + label + estMark + ''; diff --git a/src/frontend/app.js b/src/frontend/app.js index 9e6ce60..09f66aa 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -50,8 +50,12 @@ function getProjectColor(project) { return projectColorMap[project]; } -function getProjectName(fullPath) { +function getProjectName(fullPath, session) { if (!fullPath) return 'unknown'; + // For Kimi, project is a hash - show session name instead + if (session && session.tool === 'kimi' && /^[a-f0-9]{32}$/i.test(fullPath)) { + return getSessionDisplayName(session) || 'kimi-session'; + } const cleaned = fullPath.replace(/\/+$/, ''); const parts = cleaned.split('/'); return parts[parts.length - 1] || 'unknown'; @@ -87,7 +91,7 @@ function getSessionGroupInfo(session) { if (groupingMode === 'repo') { return getRepoInfo(session.project, session.git_root); } - var name = getProjectName(session.project); + var name = getProjectName(session.project, session); return { key: name, name: name }; } @@ -560,6 +564,11 @@ async function pollActiveSessions() { } } }); + + // Re-render Agent Board if on running view + if (currentView === 'running') { + render(); + } } catch {} } @@ -708,7 +717,7 @@ function renderCard(s, idx) { var sessionTags = tags[s.id] || []; var cost = estimateCost(s.file_size); var costStr = cost > 0 ? '~$' + cost.toFixed(2) : ''; - var projName = getProjectName(s.project); + var projName = getProjectName(s.project, s); var projColor = getProjectColor(projName); var toolClass = 'tool-' + s.tool; var toolLabel = s.tool === 'claude-ext' ? 'claude ext' : s.tool; @@ -803,7 +812,7 @@ function renderListCard(s, idx) { var isStarred = stars.indexOf(s.id) >= 0; var isSelected = selectedIds.has(s.id); var isFocused = focusedIndex === idx; - var projName = getProjectName(s.project); + var projName = getProjectName(s.project, s); var projColor = getProjectColor(projName); var showBadges = showAllSessionsListBadges; @@ -1449,11 +1458,13 @@ document.addEventListener('keydown', function(e) { // ── Running Sessions View (Kanban) ───────────────────────────── function renderRunningCard(a, s) { - var projName = s ? getProjectName(s.project) : (a.cwd ? a.cwd.split('/').pop() : 'unknown'); + // Show actual folder name from cwd, not session title + var projName = a.cwd ? a.cwd.split('/').pop() : (s ? getProjectName(s.project, s) : 'unknown'); var projColor = getProjectColor(projName); var statusClass = a.status === 'waiting' ? 'running-waiting' : 'running-active'; var uptime = a.startedAt ? formatDuration(Date.now() - a.startedAt) : ''; var sid = a.sessionId; + var safeSid = sid.replace(/'/g, "\\'"); var html = '
'; html += '
'; @@ -1469,10 +1480,10 @@ function renderRunningCard(a, s) { var displayName = getSessionDisplayName(s); if (displayName) html += '
' + escHtml(displayName.slice(0, 120)) + '
'; html += '
'; - html += ''; + html += ''; if (s) { - html += ''; - html += ''; + html += ''; + html += ''; } html += '
'; html += '
'; @@ -1480,7 +1491,7 @@ function renderRunningCard(a, s) { } function renderDoneCard(s) { - var projName = getProjectName(s.project); + var projName = getProjectName(s.project, s); var projColor = getProjectColor(projName); var html = '
'; html += '
'; @@ -1785,6 +1796,12 @@ var AGENT_INSTALL = { alt: 'npm i -g opencode-ai@latest', url: 'https://opencode.ai', }, + kimi: { + name: 'Kimi CLI', + cmd: 'pip install kimi-cli', + alt: 'uv tool install kimi-cli', + url: 'https://github.com/MoonshotAI/kimi-cli', + }, }; function installAgent(agent) { diff --git a/src/frontend/calendar.js b/src/frontend/calendar.js index 2e46d57..ef94e15 100644 --- a/src/frontend/calendar.js +++ b/src/frontend/calendar.js @@ -193,6 +193,9 @@ function setView(view) { } else if (view === 'opencode-only') { toolFilter = toolFilter === 'opencode' ? null : 'opencode'; currentView = 'sessions'; + } else if (view === 'kimi-only') { + toolFilter = toolFilter === 'kimi' ? null : 'kimi'; + currentView = 'sessions'; } else { toolFilter = null; currentView = view; diff --git a/src/frontend/heatmap.js b/src/frontend/heatmap.js index 17eaef8..267fed6 100644 --- a/src/frontend/heatmap.js +++ b/src/frontend/heatmap.js @@ -179,7 +179,7 @@ function renderHeatmap(container) { // Per-tool breakdown var toolTotals = {}; allSessions.forEach(function(s) { if (s.date >= yearStart) { toolTotals[s.tool] = (toolTotals[s.tool] || 0) + 1; } }); - var toolColors = { claude: '#60a5fa', codex: '#22d3ee', opencode: '#c084fc', kiro: '#fb923c' }; + var toolColors = { claude: '#60a5fa', codex: '#22d3ee', opencode: '#c084fc', kiro: '#fb923c', kimi: '#fb7185' }; html += '
'; Object.keys(toolTotals).sort(function(a,b) { return toolTotals[b] - toolTotals[a]; }).forEach(function(tool) { var pct = (toolTotals[tool] / Math.max(totalThisYear, 1) * 100).toFixed(0); diff --git a/src/frontend/index.html b/src/frontend/index.html index c8b3fd9..400c7fc 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -72,6 +72,10 @@ OpenCode
+ +