From a2db8f6c0e33c9c29321063bf49470d0668e5997 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 23 Mar 2026 06:59:59 +0900 Subject: [PATCH] refactor: extract dashboard and task helpers --- src/agent-runner.ts | 558 ++++++++++++++------------- src/dashboard.ts | 539 +++----------------------- src/index.ts | 766 +------------------------------------ src/task-scheduler.test.ts | 67 ++++ src/task-scheduler.ts | 220 +++++------ src/task-status-tracker.ts | 92 +++++ src/unified-dashboard.ts | 742 +++++++++++++++++++++++++++++++++++ 7 files changed, 1358 insertions(+), 1626 deletions(-) create mode 100644 src/task-status-tracker.ts create mode 100644 src/unified-dashboard.ts diff --git a/src/agent-runner.ts b/src/agent-runner.ts index b81537f..0848d12 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -23,7 +23,7 @@ import { readPairedRoomPrompt, readPlatformPrompt, } from './platform-prompts.js'; -import { RegisteredGroup } from './types.js'; +import { AgentType, RegisteredGroup } from './types.js'; // Sentinel markers for robust output parsing (must match agent-runner) const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---'; @@ -49,6 +49,278 @@ export interface AgentOutput { error?: string; } +function syncDirectoryEntries(sources: string[], destination: string): void { + for (const source of sources) { + if (!fs.existsSync(source)) continue; + for (const entry of fs.readdirSync(source)) { + const srcPath = path.join(source, entry); + const dstPath = path.join(destination, entry); + if (fs.statSync(srcPath).isDirectory()) { + fs.cpSync(srcPath, dstPath, { recursive: true }); + } else { + fs.mkdirSync(destination, { recursive: true }); + fs.copyFileSync(srcPath, dstPath); + } + } + } +} + +function ensureClaudeSessionSettings(groupSessionsDir: string): void { + const settingsFile = path.join(groupSessionsDir, 'settings.json'); + if (fs.existsSync(settingsFile)) return; + + fs.writeFileSync( + settingsFile, + JSON.stringify( + { + env: { + CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', + CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1', + CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0', + }, + }, + null, + 2, + ) + '\n', + ); +} + +function buildBaseRunnerEnv(args: { + group: RegisteredGroup; + chatJid: string; + isMain: boolean; + groupDir: string; + groupIpcDir: string; + globalDir: string; + groupSessionsDir: string; + agentType: AgentType; + envVars: Record; +}): Record { + const cleanEnv = { ...(process.env as Record) }; + for (const [key, value] of Object.entries(args.envVars)) { + if (value && !cleanEnv[key]) cleanEnv[key] = value; + } + delete cleanEnv.CLAUDECODE; + delete cleanEnv.CLAUDE_CODE_ENTRYPOINT; + + const nodeBin = path.dirname(process.execPath); + const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin'); + const currentPath = cleanEnv.PATH || '/usr/local/bin:/usr/bin:/bin'; + const extraPaths = [nodeBin, npmGlobalBin].filter( + (candidate) => + !currentPath.includes(candidate) && fs.existsSync(candidate), + ); + + return { + ...cleanEnv, + PATH: + extraPaths.length > 0 + ? `${extraPaths.join(':')}:${currentPath}` + : currentPath, + TZ: TIMEZONE, + HOME: os.homedir(), + NANOCLAW_GROUP_DIR: args.groupDir, + NANOCLAW_IPC_DIR: args.groupIpcDir, + NANOCLAW_GLOBAL_DIR: args.globalDir, + ...(args.group.workDir ? { NANOCLAW_WORK_DIR: args.group.workDir } : {}), + NANOCLAW_CHAT_JID: args.chatJid, + NANOCLAW_GROUP_FOLDER: args.group.folder, + NANOCLAW_IS_MAIN: args.isMain ? '1' : '0', + NANOCLAW_AGENT_TYPE: args.agentType, + CLAUDE_CONFIG_DIR: args.groupSessionsDir, + }; +} + +function prepareClaudeEnvironment(args: { + env: Record; + envVars: Record; + group: RegisteredGroup; +}): void { + if (args.envVars.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY) { + args.env.ANTHROPIC_API_KEY = + args.envVars.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY || ''; + } + if (args.envVars.ANTHROPIC_AUTH_TOKEN || process.env.ANTHROPIC_AUTH_TOKEN) { + args.env.ANTHROPIC_AUTH_TOKEN = + args.envVars.ANTHROPIC_AUTH_TOKEN || + process.env.ANTHROPIC_AUTH_TOKEN || + ''; + } + if (args.envVars.ANTHROPIC_BASE_URL || process.env.ANTHROPIC_BASE_URL) { + args.env.ANTHROPIC_BASE_URL = + args.envVars.ANTHROPIC_BASE_URL || process.env.ANTHROPIC_BASE_URL || ''; + } + if ( + args.envVars.CLAUDE_CODE_OAUTH_TOKEN || + process.env.CLAUDE_CODE_OAUTH_TOKEN + ) { + args.env.CLAUDE_CODE_OAUTH_TOKEN = + args.envVars.CLAUDE_CODE_OAUTH_TOKEN || + process.env.CLAUDE_CODE_OAUTH_TOKEN || + ''; + } + for (const key of [ + 'CLAUDE_MODEL', + 'CLAUDE_THINKING', + 'CLAUDE_THINKING_BUDGET', + 'CLAUDE_EFFORT', + ]) { + const value = + args.envVars[key as keyof typeof args.envVars] || process.env[key]; + if (value) args.env[key] = value; + } + if (args.group.agentConfig?.claudeModel) { + args.env.CLAUDE_MODEL = args.group.agentConfig.claudeModel; + } + if (args.group.agentConfig?.claudeEffort) { + args.env.CLAUDE_EFFORT = args.group.agentConfig.claudeEffort; + } +} + +function prepareCodexSessionEnvironment(args: { + env: Record; + envVars: Record; + projectRoot: string; + group: RegisteredGroup; + groupDir: string; + chatJid: string; + isMain: boolean; + isPairedRoom: boolean; +}): void { + const openaiKey = + args.envVars.CODEX_OPENAI_API_KEY || + process.env.CODEX_OPENAI_API_KEY || + args.envVars.OPENAI_API_KEY || + process.env.OPENAI_API_KEY; + if (openaiKey) args.env.OPENAI_API_KEY = openaiKey; + + const codexModel = + args.group.agentConfig?.codexModel || + args.envVars.CODEX_MODEL || + process.env.CODEX_MODEL; + if (codexModel) args.env.CODEX_MODEL = codexModel; + + const codexEffort = + args.group.agentConfig?.codexEffort || + args.envVars.CODEX_EFFORT || + process.env.CODEX_EFFORT; + if (codexEffort) args.env.CODEX_EFFORT = codexEffort; + + const hostCodexDir = path.join(os.homedir(), '.codex'); + const sessionCodexDir = path.join( + DATA_DIR, + 'sessions', + args.group.folder, + '.codex', + ); + fs.mkdirSync(sessionCodexDir, { recursive: true }); + + const authSrc = path.join(hostCodexDir, 'auth.json'); + const authDst = path.join(sessionCodexDir, 'auth.json'); + if (fs.existsSync(authSrc)) fs.copyFileSync(authSrc, authDst); + for (const file of ['config.toml', 'config.json']) { + const src = path.join(hostCodexDir, file); + const dst = path.join(sessionCodexDir, file); + if (fs.existsSync(src)) { + fs.copyFileSync(src, dst); + } + } + + const overlayPath = path.join(args.groupDir, '.codex', 'config.toml'); + const sessionConfigPath = path.join(sessionCodexDir, 'config.toml'); + if (fs.existsSync(overlayPath)) { + const overlayToml = fs.readFileSync(overlayPath, 'utf-8').trim(); + if (overlayToml) { + const baseToml = fs.existsSync(sessionConfigPath) + ? fs.readFileSync(sessionConfigPath, 'utf-8').trimEnd() + : ''; + fs.writeFileSync( + sessionConfigPath, + [baseToml, overlayToml].filter(Boolean).join('\n\n') + '\n', + ); + } + } + + const sessionAgentsPath = path.join(sessionCodexDir, 'AGENTS.md'); + const sessionAgents = [ + readPlatformPrompt('codex', args.projectRoot), + args.isPairedRoom + ? readPairedRoomPrompt('codex', args.projectRoot) + : undefined, + ] + .filter((value): value is string => Boolean(value)) + .join('\n\n---\n\n') + .trim(); + if (sessionAgents) { + fs.writeFileSync(sessionAgentsPath, sessionAgents + '\n'); + } else if (fs.existsSync(sessionAgentsPath)) { + fs.unlinkSync(sessionAgentsPath); + } + + syncDirectoryEntries( + [ + path.join(os.homedir(), '.claude', 'skills'), + path.join(args.projectRoot, 'runners', 'skills'), + ], + path.join(sessionCodexDir, 'skills'), + ); + + const mcpServerPath = path.join( + args.projectRoot, + 'runners', + 'agent-runner', + 'dist', + 'ipc-mcp-stdio.js', + ); + if (fs.existsSync(mcpServerPath)) { + let toml = fs.existsSync(sessionConfigPath) + ? fs.readFileSync(sessionConfigPath, 'utf-8') + : ''; + toml = toml.replace(/\n?\[mcp_servers\.nanoclaw\][\s\S]*?(?=\n\[|$)/, ''); + toml = toml.replace( + /\n?\[mcp_servers\.memento-mcp\][\s\S]*?(?=\n\[|$)/, + '', + ); + const mcpSection = ` +[mcp_servers.nanoclaw] +command = "node" +args = [${JSON.stringify(mcpServerPath)}] + +[mcp_servers.nanoclaw.env] +NANOCLAW_IPC_DIR = ${JSON.stringify(args.env.NANOCLAW_IPC_DIR)} +NANOCLAW_CHAT_JID = ${JSON.stringify(args.chatJid)} +NANOCLAW_GROUP_FOLDER = ${JSON.stringify(args.group.folder)} +NANOCLAW_IS_MAIN = ${JSON.stringify(args.isMain ? '1' : '0')} +NANOCLAW_AGENT_TYPE = ${JSON.stringify(args.env.NANOCLAW_AGENT_TYPE)} +`; + const mementoSseUrl = + args.envVars.MEMENTO_MCP_SSE_URL || process.env.MEMENTO_MCP_SSE_URL; + const mementoAccessKey = + args.envVars.MEMENTO_ACCESS_KEY || process.env.MEMENTO_ACCESS_KEY || ''; + const mementoRemotePath = + args.envVars.MEMENTO_MCP_REMOTE_PATH || + process.env.MEMENTO_MCP_REMOTE_PATH || + 'mcp-remote'; + const mementoSection = mementoSseUrl + ? ` +[mcp_servers.memento-mcp] +command = ${JSON.stringify(mementoRemotePath)} +args = [${JSON.stringify(mementoSseUrl)}, "--header", ${JSON.stringify(`Authorization:Bearer ${mementoAccessKey}`)}] +` + : ''; + fs.writeFileSync( + sessionConfigPath, + toml.trimEnd() + '\n' + mcpSection + mementoSection, + ); + } + + delete args.env.ANTHROPIC_API_KEY; + delete args.env.ANTHROPIC_AUTH_TOKEN; + delete args.env.ANTHROPIC_BASE_URL; + delete args.env.CLAUDE_CODE_OAUTH_TOKEN; + args.env.CODEX_HOME = sessionCodexDir; +} + /** * Prepare the group's environment: directories, sessions, env vars. * Returns the environment variables and paths for the runner process. @@ -70,24 +342,7 @@ function prepareGroupEnvironment( '.claude', ); fs.mkdirSync(groupSessionsDir, { recursive: true }); - - const settingsFile = path.join(groupSessionsDir, 'settings.json'); - if (!fs.existsSync(settingsFile)) { - fs.writeFileSync( - settingsFile, - JSON.stringify( - { - env: { - CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', - CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1', - CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0', - }, - }, - null, - 2, - ) + '\n', - ); - } + ensureClaudeSessionSettings(groupSessionsDir); // Sync skills into each group's .claude/ session dir // Sources: 1) user's global ~/.claude/skills 2) project workDir/.claude/skills 3) runners/skills/ @@ -99,20 +354,7 @@ function prepareGroupEnvironment( ...(workDirClaude ? [path.join(workDirClaude, 'skills')] : []), path.join(projectRoot, 'runners', 'skills'), ]; - const skillsDst = path.join(groupSessionsDir, 'skills'); - for (const src of skillSources) { - if (!fs.existsSync(src)) continue; - for (const entry of fs.readdirSync(src)) { - const srcPath = path.join(src, entry); - const dstPath = path.join(skillsDst, entry); - if (fs.statSync(srcPath).isDirectory()) { - fs.cpSync(srcPath, dstPath, { recursive: true }); - } else { - fs.mkdirSync(skillsDst, { recursive: true }); - fs.copyFileSync(srcPath, dstPath); - } - } - } + syncDirectoryEntries(skillSources, path.join(groupSessionsDir, 'skills')); // Per-group IPC namespace const groupIpcDir = resolveGroupIpcPath(group.folder); @@ -172,240 +414,32 @@ function prepareGroupEnvironment( 'MEMENTO_MCP_REMOTE_PATH', ]); - // Build a clean env without Claude Code nesting detection variables - const cleanEnv = { ...(process.env as Record) }; - // Merge .env file values (readEnvFile only reads file, doesn't set process.env) - for (const [k, v] of Object.entries(envVars)) { - if (v && !cleanEnv[k]) cleanEnv[k] = v; - } - delete cleanEnv.CLAUDECODE; - delete cleanEnv.CLAUDE_CODE_ENTRYPOINT; - - // Ensure node and npm-global binaries (codex, etc.) are findable - const nodeBin = path.dirname(process.execPath); - const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin'); - const currentPath = cleanEnv.PATH || '/usr/local/bin:/usr/bin:/bin'; - const extraPaths = [nodeBin, npmGlobalBin].filter( - (p) => !currentPath.includes(p) && fs.existsSync(p), - ); - const enrichedPath = - extraPaths.length > 0 - ? `${extraPaths.join(':')}:${currentPath}` - : currentPath; - - const env: Record = { - ...cleanEnv, - PATH: enrichedPath, - TZ: TIMEZONE, - HOME: os.homedir(), - // Path configuration for the runner - NANOCLAW_GROUP_DIR: groupDir, - NANOCLAW_IPC_DIR: groupIpcDir, - NANOCLAW_GLOBAL_DIR: globalDir, - // Working directory override (agent uses this as cwd instead of group dir) - ...(group.workDir ? { NANOCLAW_WORK_DIR: group.workDir } : {}), - // MCP server context - NANOCLAW_CHAT_JID: chatJid, - NANOCLAW_GROUP_FOLDER: group.folder, - NANOCLAW_IS_MAIN: isMain ? '1' : '0', - NANOCLAW_AGENT_TYPE: agentType, - // Claude sessions directory β€” set CLAUDE_CONFIG_DIR so SDK uses per-group sessions - CLAUDE_CONFIG_DIR: groupSessionsDir, - }; + const env = buildBaseRunnerEnv({ + group, + chatJid, + isMain, + groupDir, + groupIpcDir, + globalDir, + groupSessionsDir, + agentType, + envVars, + }); // Pass credentials directly (no proxy needed on host) if (agentType === 'codex') { - const openaiKey = - envVars.CODEX_OPENAI_API_KEY || - process.env.CODEX_OPENAI_API_KEY || - envVars.OPENAI_API_KEY || - process.env.OPENAI_API_KEY; - if (openaiKey) env.OPENAI_API_KEY = openaiKey; - - // Codex model/effort configuration (per-group overrides global) - const codexModel = - group.agentConfig?.codexModel || - envVars.CODEX_MODEL || - process.env.CODEX_MODEL; - if (codexModel) env.CODEX_MODEL = codexModel; - const codexEffort = - group.agentConfig?.codexEffort || - envVars.CODEX_EFFORT || - process.env.CODEX_EFFORT; - if (codexEffort) env.CODEX_EFFORT = codexEffort; - - // Codex session directory - const hostCodexDir = path.join(os.homedir(), '.codex'); - const sessionCodexDir = path.join( - DATA_DIR, - 'sessions', - group.folder, - '.codex', - ); - fs.mkdirSync(sessionCodexDir, { recursive: true }); - const authSrc = path.join(hostCodexDir, 'auth.json'); - const authDst = path.join(sessionCodexDir, 'auth.json'); - if (fs.existsSync(authSrc)) fs.copyFileSync(authSrc, authDst); - for (const file of ['config.toml', 'config.json']) { - const src = path.join(hostCodexDir, file); - const dst = path.join(sessionCodexDir, file); - if (fs.existsSync(src)) { - fs.copyFileSync(src, dst); - } - } - const groupCodexConfigOverlayPath = path.join( - groupDir, - '.codex', - 'config.toml', - ); - const sessionCodexConfigPath = path.join(sessionCodexDir, 'config.toml'); - if (fs.existsSync(groupCodexConfigOverlayPath)) { - const overlayToml = fs - .readFileSync(groupCodexConfigOverlayPath, 'utf-8') - .trim(); - if (overlayToml) { - const baseToml = fs.existsSync(sessionCodexConfigPath) - ? fs.readFileSync(sessionCodexConfigPath, 'utf-8').trimEnd() - : ''; - const mergedToml = [baseToml, overlayToml] - .filter((value) => value.length > 0) - .join('\n\n'); - fs.writeFileSync(sessionCodexConfigPath, mergedToml + '\n'); - } - } - const sessionAgentsPath = path.join(sessionCodexDir, 'AGENTS.md'); - const codexPlatformPrompt = readPlatformPrompt('codex', projectRoot); - const codexPairedRoomPrompt = isPairedRoom - ? readPairedRoomPrompt('codex', projectRoot) - : undefined; - const sessionAgents = [codexPlatformPrompt, codexPairedRoomPrompt] - .filter((value): value is string => Boolean(value)) - .join('\n\n---\n\n') - .trim(); - if (sessionAgents) { - fs.writeFileSync(sessionAgentsPath, sessionAgents + '\n'); - } else if (fs.existsSync(sessionAgentsPath)) { - fs.unlinkSync(sessionAgentsPath); - } - // Sync skills into Codex session dir - // SSOT: ~/.claude/skills/ (shared with Claude Code) + runners/skills/ - const codexSkillSources = [ - path.join(os.homedir(), '.claude', 'skills'), - path.join(projectRoot, 'runners', 'skills'), - ]; - const codexSkillsDst = path.join(sessionCodexDir, 'skills'); - for (const src of codexSkillSources) { - if (!fs.existsSync(src)) continue; - for (const entry of fs.readdirSync(src)) { - const srcPath = path.join(src, entry); - const dstPath = path.join(codexSkillsDst, entry); - if (fs.statSync(srcPath).isDirectory()) { - fs.cpSync(srcPath, dstPath, { recursive: true }); - } else { - fs.mkdirSync(codexSkillsDst, { recursive: true }); - fs.copyFileSync(srcPath, dstPath); - } - } - } - - // Inject nanoclaw MCP server into Codex config.toml - const mcpServerPath = path.join( + prepareCodexSessionEnvironment({ + env, + envVars, projectRoot, - 'runners', - 'agent-runner', - 'dist', - 'ipc-mcp-stdio.js', - ); - const configTomlPath = path.join(sessionCodexDir, 'config.toml'); - if (fs.existsSync(mcpServerPath)) { - let toml = fs.existsSync(configTomlPath) - ? fs.readFileSync(configTomlPath, 'utf-8') - : ''; - // Remove existing nanoclaw/memento MCP sections if present (to refresh env vars) - toml = toml.replace(/\n?\[mcp_servers\.nanoclaw\][\s\S]*?(?=\n\[|$)/, ''); - toml = toml.replace( - /\n?\[mcp_servers\.memento-mcp\][\s\S]*?(?=\n\[|$)/, - '', - ); - const mcpSection = ` -[mcp_servers.nanoclaw] -command = "node" -args = [${JSON.stringify(mcpServerPath)}] - -[mcp_servers.nanoclaw.env] -NANOCLAW_IPC_DIR = ${JSON.stringify(env.NANOCLAW_IPC_DIR)} -NANOCLAW_CHAT_JID = ${JSON.stringify(chatJid)} -NANOCLAW_GROUP_FOLDER = ${JSON.stringify(group.folder)} -NANOCLAW_IS_MAIN = ${JSON.stringify(isMain ? '1' : '0')} -NANOCLAW_AGENT_TYPE = ${JSON.stringify(env.NANOCLAW_AGENT_TYPE)} -`; - // Inject memento-mcp if MEMENTO_MCP_SSE_URL is set - const mementoSseUrl = - envVars.MEMENTO_MCP_SSE_URL || process.env.MEMENTO_MCP_SSE_URL; - const mementoAccessKey = - envVars.MEMENTO_ACCESS_KEY || process.env.MEMENTO_ACCESS_KEY || ''; - const mementoRemotePath = - envVars.MEMENTO_MCP_REMOTE_PATH || - process.env.MEMENTO_MCP_REMOTE_PATH || - 'mcp-remote'; - const mementoSection = mementoSseUrl - ? ` -[mcp_servers.memento-mcp] -command = ${JSON.stringify(mementoRemotePath)} -args = [${JSON.stringify(mementoSseUrl)}, "--header", ${JSON.stringify(`Authorization:Bearer ${mementoAccessKey}`)}] -` - : ''; - - toml = toml.trimEnd() + '\n' + mcpSection + mementoSection; - fs.writeFileSync(configTomlPath, toml); - } - - // Sanitize secrets: prevent API keys from leaking to codex subprocesses - delete env.ANTHROPIC_API_KEY; - delete env.ANTHROPIC_AUTH_TOKEN; - delete env.ANTHROPIC_BASE_URL; - delete env.CLAUDE_CODE_OAUTH_TOKEN; - - env.CODEX_HOME = sessionCodexDir; + group, + groupDir, + chatJid, + isMain, + isPairedRoom, + }); } else { - // Claude Code β€” pass real credentials directly - if (envVars.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY) { - env.ANTHROPIC_API_KEY = - envVars.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY || ''; - } - if (envVars.ANTHROPIC_AUTH_TOKEN || process.env.ANTHROPIC_AUTH_TOKEN) { - env.ANTHROPIC_AUTH_TOKEN = - envVars.ANTHROPIC_AUTH_TOKEN || process.env.ANTHROPIC_AUTH_TOKEN || ''; - } - if (envVars.ANTHROPIC_BASE_URL || process.env.ANTHROPIC_BASE_URL) { - env.ANTHROPIC_BASE_URL = - envVars.ANTHROPIC_BASE_URL || process.env.ANTHROPIC_BASE_URL || ''; - } - if ( - envVars.CLAUDE_CODE_OAUTH_TOKEN || - process.env.CLAUDE_CODE_OAUTH_TOKEN - ) { - env.CLAUDE_CODE_OAUTH_TOKEN = - envVars.CLAUDE_CODE_OAUTH_TOKEN || - process.env.CLAUDE_CODE_OAUTH_TOKEN || - ''; - } - // Model/thinking config (per-group overrides global) - for (const key of [ - 'CLAUDE_MODEL', - 'CLAUDE_THINKING', - 'CLAUDE_THINKING_BUDGET', - 'CLAUDE_EFFORT', - ]) { - const val = envVars[key as keyof typeof envVars] || process.env[key]; - if (val) env[key] = val; - } - if (group.agentConfig?.claudeModel) { - env.CLAUDE_MODEL = group.agentConfig.claudeModel; - } - if (group.agentConfig?.claudeEffort) { - env.CLAUDE_EFFORT = group.agentConfig.claudeEffort; - } + prepareClaudeEnvironment({ env, envVars, group }); } return { env, groupDir, runnerDir }; diff --git a/src/dashboard.ts b/src/dashboard.ts index e969190..1825328 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -1,23 +1,13 @@ -import { ChildProcess, execSync, spawn } from 'child_process'; -import fs from 'fs'; -import os from 'os'; -import path from 'path'; - -import { - fetchClaudeUsageViaCli, - type ClaudeUsageData, -} from './claude-usage.js'; -import { STATUS_SHOW_ROOMS, USAGE_DASHBOARD_ENABLED } from './config.js'; +import { STATUS_SHOW_ROOMS } from './config.js'; import { composeDashboardContent, type DashboardRoomLine, - getStatusLabel as formatDashboardStatusLabel, + getStatusLabel, renderCategorizedRoomSections, } from './dashboard-render.js'; -import { readEnvFile } from './env.js'; -import { GroupQueue, GroupStatus } from './group-queue.js'; -import { logger } from './logger.js'; -import { Channel, ChannelMeta, RegisteredGroup } from './types.js'; +import type { GroupQueue } from './group-queue.js'; +import { startUnifiedDashboard } from './unified-dashboard.js'; +import type { AgentType, Channel, RegisteredGroup } from './types.js'; export interface DashboardOptions { assistantName: string; @@ -28,88 +18,7 @@ export interface DashboardOptions { statusChannelId: string; statusUpdateInterval: number; usageUpdateInterval: number; -} - -interface CodexRateLimit { - limitId?: string; - limitName: string | null; - primary: { usedPercent: number; resetsAt: string | number }; - secondary: { usedPercent: number; resetsAt: string | number }; -} - -const STATUS_ICONS: Record = { - processing: '🟑', - waiting: 'πŸ”΅', - inactive: 'βšͺ', -}; - -const CHANNEL_META_REFRESH_MS = 300000; - -let statusMessageId: string | null = null; -let usageMessageId: string | null = null; -let usageUpdateInProgress = false; -let channelMetaCache = new Map(); -let channelMetaLastRefresh = 0; - -function findDiscordChannel(channels: Channel[]): Channel | undefined { - return channels.find((c) => c.name.startsWith('discord') && c.isConnected()); -} - -function formatElapsed(ms: number): string { - const s = Math.floor(ms / 1000); - if (s < 60) return `${s}s`; - if (s < 3600) { - const m = Math.floor(s / 60); - const rem = s % 60; - return `${m}m${rem.toString().padStart(2, '0')}s`; - } - const h = Math.floor(s / 3600); - const m = Math.floor((s % 3600) / 60); - if (h < 24) return `${h}h${m.toString().padStart(2, '0')}m`; - const d = Math.floor(h / 24); - const remH = h % 24; - return `${d}d${remH}h`; -} - -function formatResetKST(value: string | number): string { - try { - const date = - typeof value === 'number' ? new Date(value * 1000) : new Date(value); - if (Number.isNaN(date.getTime())) return String(value); - return date.toLocaleString('ko-KR', { - timeZone: 'Asia/Seoul', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); - } catch { - return String(value); - } -} - -async function refreshChannelMeta(opts: DashboardOptions): Promise { - const now = Date.now(); - if (now - channelMetaLastRefresh < CHANNEL_META_REFRESH_MS) return; - - const ch = opts.channels.find( - (c) => c.name.startsWith('discord') && c.isConnected() && c.getChannelMeta, - ); - if (!ch?.getChannelMeta) return; - - const jids = Object.keys(opts.registeredGroups()).filter((j) => - j.startsWith('dc:'), - ); - try { - channelMetaCache = await ch.getChannelMeta(jids); - channelMetaLastRefresh = now; - } catch (err) { - logger.debug({ err }, 'Failed to refresh channel metadata'); - } -} - -function getStatusLabel(status: GroupStatus): string { - return formatDashboardStatusLabel(status); + serviceAgentType?: AgentType; } function getSessionLabel(sessionId: string | undefined): string { @@ -118,335 +27,36 @@ function getSessionLabel(sessionId: string | undefined): string { return `μ„Έμ…˜ ${shortId}`; } -/** @internal - exported for testing */ export function buildStatusContent(opts: DashboardOptions): string { if (!STATUS_SHOW_ROOMS) return ''; - const registeredGroups = opts.registeredGroups(); const sessions = opts.getSessions(); - const jids = Object.keys(registeredGroups); - const statuses = opts.queue.getStatuses(jids); + const groups = opts.registeredGroups(); + const statuses = opts.queue.getStatuses(Object.keys(groups)); - const entries = statuses - .map((status) => ({ - status, - group: registeredGroups[status.jid], - meta: channelMetaCache.get(status.jid), - })) - .filter((entry) => entry.group); - - const categoryMap = new Map(); - for (const entry of entries) { - const category = entry.meta?.category || '기타'; - if (!categoryMap.has(category)) categoryMap.set(category, []); - categoryMap.get(category)!.push(entry); - } - - const sortedCategories = [...categoryMap.entries()].sort((a, b) => { - const posA = a[1][0]?.meta?.categoryPosition ?? 999; - const posB = b[1][0]?.meta?.categoryPosition ?? 999; - return posA - posB; - }); - - const roomLines: DashboardRoomLine[] = []; let totalActive = 0; let totalWaiting = 0; - let total = 0; + const roomLines: DashboardRoomLine[] = statuses + .map((status) => { + const group = groups[status.jid]; + if (!group) return null; + if (status.status === 'processing') totalActive += 1; + if (status.status === 'waiting') totalWaiting += 1; + return { + category: '기타', + categoryPosition: 999, + position: 999, + line: ` **${group.name}** β€” ${getStatusLabel(status)} Β· ${getSessionLabel(sessions[group.folder])}`, + }; + }) + .filter((line): line is DashboardRoomLine => Boolean(line)); - for (const [categoryName, categoryEntries] of sortedCategories) { - categoryEntries.sort( - (a, b) => (a.meta?.position ?? 999) - (b.meta?.position ?? 999), - ); - - categoryEntries.forEach((entry) => { - const icon = STATUS_ICONS[entry.status.status] || 'βšͺ'; - const label = getStatusLabel(entry.status); - const sessionLabel = getSessionLabel(sessions[entry.group.folder]); - const name = entry.meta?.name ? `#${entry.meta.name}` : entry.group.name; - roomLines.push({ - category: categoryName, - categoryPosition: entry.meta?.categoryPosition ?? 999, - position: entry.meta?.position ?? 999, - line: ` ${icon} **${name}** β€” ${label} Β· ${sessionLabel}`, - }); - }); - - totalActive += categoryEntries.filter( - (entry) => entry.status.status === 'processing', - ).length; - totalWaiting += categoryEntries.filter( - (entry) => entry.status.status === 'waiting', - ).length; - total += categoryEntries.length; - } - - const header = `**μ—μ΄μ „νŠΈ μƒνƒœ** (${opts.assistantName}) β€” ν™œμ„± ${totalActive} | νλŒ€κΈ° ${totalWaiting} | 전체 ${total}`; - return composeDashboardContent( - [ - `${header}\n\n${renderCategorizedRoomSections({ - lines: roomLines, - showCategoryHeaders: channelMetaCache.size > 0, - })}`, - ], - new Date(), - ); -} - -async function fetchClaudeUsage(): Promise { - const cliUsage = await fetchClaudeUsageViaCli(); - if (cliUsage) return cliUsage; - - try { - const envToken = readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN']); - let token = - process.env.CLAUDE_CODE_OAUTH_TOKEN || - envToken.CLAUDE_CODE_OAUTH_TOKEN || - ''; - if (!token) { - const configDir = - process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); - const credsPath = path.join(configDir, '.credentials.json'); - if (!fs.existsSync(credsPath)) return null; - const creds = JSON.parse(fs.readFileSync(credsPath, 'utf-8')); - token = creds?.claudeAiOauth?.accessToken || ''; - } - if (!token) return null; - - const res = await fetch('https://api.anthropic.com/api/oauth/usage', { - headers: { - Authorization: `Bearer ${token}`, - 'anthropic-beta': 'oauth-2025-04-20', - }, - }); - if (!res.ok) return null; - return (await res.json()) as ClaudeUsageData; - } catch { - return null; - } -} - -async function fetchCodexUsage(): Promise { - const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin', 'codex'); - const codexBin = fs.existsSync(npmGlobalBin) ? npmGlobalBin : 'codex'; - - return new Promise((resolve) => { - let done = false; - const finish = (value: CodexRateLimit[] | null) => { - if (done) return; - done = true; - clearTimeout(timer); - if (proc) { - try { - proc.kill(); - } catch { - /* ignore */ - } - } - resolve(value); - }; - - const timer = setTimeout(() => finish(null), 20000); - - let proc: ChildProcess | null = null; - try { - proc = spawn(codexBin, ['app-server'], { - stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...(process.env as Record), - PATH: [ - path.dirname(process.execPath), - path.join(os.homedir(), '.npm-global', 'bin'), - process.env.PATH || '', - ].join(':'), - }, - }); - } catch { - resolve(null); - return; - } - - if (!proc.stdout || !proc.stdin) { - finish(null); - return; - } - - const stdout = proc.stdout; - const stdin = proc.stdin; - - proc.on('error', () => finish(null)); - proc.on('close', () => finish(null)); - - let buf = ''; - stdout.on('data', (chunk: Buffer) => { - buf += chunk.toString(); - const lines = buf.split('\n'); - buf = lines.pop() || ''; - for (const line of lines) { - if (!line.trim()) continue; - try { - const msg = JSON.parse(line); - if (msg.id === 1) { - stdin.write( - JSON.stringify({ - jsonrpc: '2.0', - id: 2, - method: 'account/rateLimits/read', - params: {}, - }) + '\n', - ); - } else if (msg.id === 2 && msg.result) { - const byId = msg.result.rateLimitsByLimitId; - if (byId && typeof byId === 'object') { - finish(Object.values(byId) as CodexRateLimit[]); - } else { - finish(null); - } - } - } catch { - /* non-JSON line */ - } - } - }); - - stdin.write( - JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { clientInfo: { name: 'usage-monitor', version: '1.0' } }, - }) + '\n', - ); - }); -} - -async function buildUsageContent(): Promise { - const lines: string[] = []; - const [claudeUsage, codexUsage] = await Promise.all([ - fetchClaudeUsage(), - fetchCodexUsage(), + return composeDashboardContent([ + `**μ—μ΄μ „νŠΈ μƒνƒœ** (${opts.assistantName}) β€” ν™œμ„± ${totalActive} | νλŒ€κΈ° ${totalWaiting} | 전체 ${roomLines.length}\n\n${renderCategorizedRoomSections({ + lines: roomLines, + showCategoryHeaders: false, + })}`, ]); - - const bar = (pct: number) => { - const filled = Math.round(pct / 10); - return 'β–ˆ'.repeat(filled) + 'β–‘'.repeat(10 - filled); - }; - - lines.push('πŸ“Š *μ‚¬μš©λŸ‰*'); - - type UsageRow = { - name: string; - h5pct: number; - h5reset: string; - d7pct: number; - d7reset: string; - }; - const rows: UsageRow[] = []; - - if (claudeUsage) { - const h5 = claudeUsage.five_hour; - const d7 = claudeUsage.seven_day; - rows.push({ - name: 'Claude', - h5pct: h5 - ? h5.utilization > 1 - ? Math.round(h5.utilization) - : Math.round(h5.utilization * 100) - : -1, - h5reset: h5 ? formatResetKST(h5.resets_at) : '', - d7pct: d7 - ? d7.utilization > 1 - ? Math.round(d7.utilization) - : Math.round(d7.utilization * 100) - : -1, - d7reset: d7 ? formatResetKST(d7.resets_at) : '', - }); - } - - if (codexUsage && Array.isArray(codexUsage)) { - const relevant = codexUsage.filter( - (limit) => - limit.primary.usedPercent > 0 || limit.secondary.usedPercent > 0, - ); - const display = relevant.length > 0 ? relevant : codexUsage.slice(0, 1); - for (const limit of display) { - rows.push({ - name: 'Codex', - h5pct: Math.round(limit.primary.usedPercent), - h5reset: formatResetKST(limit.primary.resetsAt), - d7pct: Math.round(limit.secondary.usedPercent), - d7reset: formatResetKST(limit.secondary.resetsAt), - }); - } - } - - if (rows.length > 0) { - lines.push('```'); - lines.push(' 5-Hour 7-Day'); - for (const row of rows) { - const h5 = - row.h5pct >= 0 - ? `${bar(row.h5pct)} ${String(row.h5pct).padStart(3)}%` - : ' β€” '; - const d7 = - row.d7pct >= 0 - ? `${bar(row.d7pct)} ${String(row.d7pct).padStart(3)}%` - : ' β€” '; - lines.push(`${row.name.padEnd(8)}${h5} ${d7}`); - } - lines.push('```'); - } else { - lines.push('_쑰회 λΆˆκ°€_'); - } - lines.push(''); - - lines.push('πŸ–₯️ *μ„œλ²„*'); - - const loadAvg = os.loadavg(); - const cpuCount = os.cpus().length; - const cpuPct = Math.round((loadAvg[1] / cpuCount) * 100); - - const totalMem = os.totalmem(); - const usedMem = totalMem - os.freemem(); - const memPct = Math.round((usedMem / totalMem) * 100); - const memUsedGB = (usedMem / 1073741824).toFixed(1); - const memTotalGB = (totalMem / 1073741824).toFixed(1); - - let diskPct = 0; - let diskUsedGB = '?'; - let diskTotalGB = '?'; - try { - const df = execSync('df -B1 / | tail -1', { - encoding: 'utf-8', - timeout: 5000, - }).trim(); - const parts = df.split(/\s+/); - const diskUsed = parseInt(parts[2], 10); - const diskTotal = parseInt(parts[1], 10); - diskPct = Math.round((diskUsed / diskTotal) * 100); - diskUsedGB = (diskUsed / 1073741824).toFixed(0); - diskTotalGB = (diskTotal / 1073741824).toFixed(0); - } catch { - /* ignore */ - } - - lines.push('```'); - lines.push(`${'CPU'.padEnd(8)}${bar(cpuPct)} ${String(cpuPct).padStart(3)}%`); - lines.push( - `${'Memory'.padEnd(8)}${bar(memPct)} ${String(memPct).padStart(3)}% ${memUsedGB}/${memTotalGB}GB`, - ); - lines.push( - `${'Disk'.padEnd(8)}${bar(diskPct)} ${String(diskPct).padStart(3)}% ${diskUsedGB}/${diskTotalGB}GB`, - ); - lines.push(`${'Uptime'.padEnd(8)}${formatElapsed(os.uptime() * 1000)}`); - lines.push('```'); - - return ( - lines.join('\n') + - `\n_${new Date().toLocaleTimeString('ko-KR', { - hour: '2-digit', - minute: '2-digit', - })}_` - ); } export async function purgeDashboardChannel( @@ -455,89 +65,32 @@ export async function purgeDashboardChannel( if (!opts.statusChannelId) return; const statusJid = `dc:${opts.statusChannelId}`; - const ch = opts.channels.find( - (channel) => - channel.name.startsWith('discord') && - channel.isConnected() && - channel.purgeChannel, + const channel = opts.channels.find( + (item) => + item.name.startsWith('discord') && + item.isConnected() && + item.purgeChannel, ); - if (ch?.purgeChannel) { - await ch.purgeChannel(statusJid); + if (channel?.purgeChannel) { + await channel.purgeChannel(statusJid); } } export async function startStatusDashboard( opts: DashboardOptions, ): Promise { - if (!opts.statusChannelId) return; - - const statusJid = `dc:${opts.statusChannelId}`; - - const updateStatus = async () => { - const ch = findDiscordChannel(opts.channels); - if (!ch) return; - - try { - await refreshChannelMeta(opts); - const content = buildStatusContent(opts); - if (!content) { - statusMessageId = null; - return; - } - - if (statusMessageId && ch.editMessage) { - await ch.editMessage(statusJid, statusMessageId, content); - } else if (ch.sendAndTrack) { - const id = await ch.sendAndTrack(statusJid, content); - if (id) statusMessageId = id; - } - } catch (err) { - logger.debug({ err }, 'Status dashboard update failed'); - statusMessageId = null; - } - }; - - setInterval(updateStatus, opts.statusUpdateInterval); - await updateStatus(); - logger.info({ channelId: opts.statusChannelId }, 'Status dashboard started'); + await startUnifiedDashboard({ + assistantName: opts.assistantName, + serviceAgentType: opts.serviceAgentType || 'claude-code', + statusChannelId: opts.statusChannelId, + statusUpdateInterval: opts.statusUpdateInterval, + usageUpdateInterval: opts.usageUpdateInterval, + channels: opts.channels, + queue: opts.queue, + registeredGroups: opts.registeredGroups, + }); } -export async function startUsageDashboard( - opts: DashboardOptions, -): Promise { - if (!opts.statusChannelId) return; - if (!USAGE_DASHBOARD_ENABLED) return; - - const statusJid = `dc:${opts.statusChannelId}`; - - const updateUsage = async () => { - if (usageUpdateInProgress) return; - usageUpdateInProgress = true; - - const ch = findDiscordChannel(opts.channels); - if (!ch) { - usageUpdateInProgress = false; - return; - } - - try { - const content = await buildUsageContent(); - - if (usageMessageId && ch.editMessage) { - await ch.editMessage(statusJid, usageMessageId, content); - } else if (ch.sendAndTrack) { - const id = await ch.sendAndTrack(statusJid, content); - if (id) usageMessageId = id; - } - } catch (err) { - logger.debug({ err }, 'Usage dashboard update failed'); - usageMessageId = null; - } finally { - usageUpdateInProgress = false; - } - }; - - setInterval(updateUsage, opts.usageUpdateInterval); - await updateUsage(); - logger.info('Usage dashboard started'); +export async function startUsageDashboard(): Promise { + // Usage dashboard is integrated into startStatusDashboard via unified dashboard. } diff --git a/src/index.ts b/src/index.ts index c527bb0..826b18a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,4 @@ -import { ChildProcess, execSync, spawn } from 'child_process'; import fs from 'fs'; -import os from 'os'; import path from 'path'; import { @@ -11,11 +9,9 @@ import { SERVICE_AGENT_TYPE, isSessionCommandSenderAllowed, STATUS_CHANNEL_ID, - STATUS_SHOW_ROOMS, STATUS_UPDATE_INTERVAL, TIMEZONE, TRIGGER_PATTERN, - USAGE_DASHBOARD_ENABLED, USAGE_UPDATE_INTERVAL, } from './config.js'; import './channels/index.js'; @@ -26,7 +22,6 @@ import { import { writeGroupsSnapshot } from './agent-runner.js'; import { listAvailableGroups } from './available-groups.js'; import { - getAllChats, getAllRegisteredGroups, getAllSessions, getAllTasks, @@ -37,20 +32,13 @@ import { initDatabase, setRegisteredGroup, setRouterState, - updateRegisteredGroupName, deleteSession, setSession, storeChatMetadata, storeMessage, } from './db.js'; -import { - composeDashboardContent, - formatElapsed, - getStatusLabel as formatDashboardStatusLabel, - renderCategorizedRoomSections, -} from './dashboard-render.js'; +import { composeDashboardContent } from './dashboard-render.js'; import { GroupQueue } from './group-queue.js'; -import { readEnvFile } from './env.js'; import { resolveGroupFolderPath } from './group-folder.js'; import { startIpcWatcher } from './ipc.js'; import { findChannel, formatOutbound } from './router.js'; @@ -70,12 +58,9 @@ import { shouldDropMessage, } from './sender-allowlist.js'; import { createMessageRuntime } from './message-runtime.js'; -import { - readStatusSnapshots, - writeStatusSnapshot, -} from './status-dashboard.js'; import { startSchedulerLoop } from './task-scheduler.js'; -import { Channel, ChannelMeta, NewMessage, RegisteredGroup } from './types.js'; +import { startUnifiedDashboard } from './unified-dashboard.js'; +import { Channel, NewMessage, RegisteredGroup } from './types.js'; import { logger } from './logger.js'; import { normalizeStoredSeqCursor } from './message-cursor.js'; @@ -240,722 +225,6 @@ export function _setRegisteredGroups( registeredGroups = groups; } -// ── Status & Usage Dashboards ─────────────────────────────────── - -function formatBytes(bytes: number): string { - if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)}GB`; - if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(0)}MB`; - return `${(bytes / 1024).toFixed(0)}KB`; -} - -function usageEmoji(pct: number): string { - if (pct >= 80) return 'πŸ”΄'; - if (pct >= 50) return '🟑'; - return '🟒'; -} - -function formatResetKST(value: string | number): string { - try { - // Handle unix timestamp (seconds) or ISO string - const date = - typeof value === 'number' ? new Date(value * 1000) : new Date(value); - return date.toLocaleString('ko-KR', { - timeZone: 'Asia/Seoul', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); - } catch { - return String(value); - } -} - -const STATUS_ICONS: Record = { - processing: '🟑', - idle: '🟒', - waiting: 'πŸ”΅', - inactive: 'βšͺ', -}; - -let statusMessageId: string | null = null; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -- kept for compat -let usageMessageId: string | null = null; -let cachedUsageContent: string = ''; -let cachedClaudeUsageData: ClaudeUsageData | null = null; - -// Cache for Discord channel metadata (name, position, category) -let channelMetaCache = new Map(); -let channelMetaLastRefresh = 0; -const CHANNEL_META_REFRESH_MS = 300000; // 5 minutes -const STATUS_SNAPSHOT_MAX_AGE_MS = 60000; - -async function refreshChannelMeta(): Promise { - const now = Date.now(); - if (now - channelMetaLastRefresh < CHANNEL_META_REFRESH_MS) return; - - const ch = channels.find( - (c) => c.name.startsWith('discord') && c.isConnected() && c.getChannelMeta, - ); - if (!ch?.getChannelMeta) return; - - // Include jids from both local registeredGroups and other service snapshots - const localJids = Object.keys(registeredGroups).filter((j) => - j.startsWith('dc:'), - ); - const snapshotJids = readStatusSnapshots(STATUS_SNAPSHOT_MAX_AGE_MS) - .flatMap((s) => s.entries.map((e) => e.jid)) - .filter((j) => j.startsWith('dc:')); - const jids = [...new Set([...localJids, ...snapshotJids])]; - try { - channelMetaCache = await ch.getChannelMeta(jids); - channelMetaLastRefresh = now; - - // Auto-sync DB group names with Discord channel names - for (const [jid, meta] of channelMetaCache) { - if (!meta.name) continue; - const group = registeredGroups[jid]; - if (group && group.name !== meta.name) { - logger.info( - { jid, oldName: group.name, newName: meta.name }, - 'Syncing group name to Discord channel name', - ); - group.name = meta.name; - updateRegisteredGroupName(jid, meta.name); - } - } - } catch (err) { - logger.debug({ err }, 'Failed to refresh channel metadata'); - } -} - -function getStatusLabel(s: { - status: 'processing' | 'waiting' | 'inactive'; - elapsedMs: number | null; - pendingMessages: boolean; - pendingTasks: number; -}): string { - return formatDashboardStatusLabel({ - status: s.status, - elapsedMs: s.elapsedMs, - pendingTasks: s.pendingTasks, - }); -} - -function getAgentDisplayName(agentType: 'claude-code' | 'codex'): string { - return agentType === 'codex' ? 'μ½”λ±μŠ€' : '클코'; -} - -function formatRoomName( - jid: string, - meta: ChannelMeta | undefined, - fallbackName: string | undefined, - chatName: string | undefined, -): string { - const base = - meta?.name || - (chatName && chatName !== jid ? chatName : undefined) || - (fallbackName && fallbackName !== jid ? fallbackName : undefined) || - jid; - - if (jid.startsWith('dc:') && base !== jid && !base.startsWith('#')) { - return `#${base}`; - } - return base; -} - -function writeLocalStatusSnapshot(): void { - const jids = Object.keys(registeredGroups); - const statuses = queue.getStatuses(jids); - - writeStatusSnapshot({ - agentType: SERVICE_AGENT_TYPE, - assistantName: ASSISTANT_NAME, - updatedAt: new Date().toISOString(), - entries: statuses - .map((status) => { - const group = registeredGroups[status.jid]; - if (!group) return null; - return { - jid: status.jid, - name: group.name, - folder: group.folder, - agentType: (group.agentType || SERVICE_AGENT_TYPE) as - | 'claude-code' - | 'codex', - status: status.status, - elapsedMs: status.elapsedMs, - pendingMessages: status.pendingMessages, - pendingTasks: status.pendingTasks, - }; - }) - .filter( - ( - entry, - ): entry is { - jid: string; - name: string; - folder: string; - agentType: 'claude-code' | 'codex'; - status: 'processing' | 'waiting' | 'inactive'; - elapsedMs: number | null; - pendingMessages: boolean; - pendingTasks: number; - } => Boolean(entry), - ), - }); -} - -function buildStatusContent(): string { - if (!STATUS_SHOW_ROOMS) return ''; - - const snapshots = readStatusSnapshots(STATUS_SNAPSHOT_MAX_AGE_MS); - const chatNameByJid = new Map( - getAllChats().map((chat) => [chat.jid, chat.name]), - ); - - // Collect all entries keyed by jid, with agent type info - interface RoomEntry { - agentType: 'claude-code' | 'codex'; - status: 'processing' | 'waiting' | 'inactive'; - elapsedMs: number | null; - pendingMessages: boolean; - pendingTasks: number; - name: string; - meta: ChannelMeta | undefined; - } - const byJid = new Map(); - - for (const snapshot of snapshots) { - const agentType = snapshot.agentType as 'claude-code' | 'codex'; - for (const entry of snapshot.entries) { - const arr = byJid.get(entry.jid) || []; - arr.push({ - agentType, - status: entry.status, - elapsedMs: entry.elapsedMs, - pendingMessages: entry.pendingMessages, - pendingTasks: entry.pendingTasks, - name: entry.name, - meta: channelMetaCache.get(entry.jid), - }); - byJid.set(entry.jid, arr); - } - } - - // Group by category, then render rooms - interface RoomInfo { - jid: string; - name: string; - meta: ChannelMeta | undefined; - agents: RoomEntry[]; - } - const categoryMap = new Map(); - let totalActive = 0; - let totalRooms = 0; - - for (const [jid, agents] of byJid) { - const meta = agents[0]?.meta; - const cat = meta?.category || '기타'; - if (!categoryMap.has(cat)) categoryMap.set(cat, []); - categoryMap.get(cat)!.push({ - jid, - name: formatRoomName( - jid, - meta, - agents.find((agent) => agent.name && agent.name !== jid)?.name, - chatNameByJid.get(jid), - ), - meta, - agents, - }); - totalRooms++; - if (agents.some((a) => a.status === 'processing')) totalActive++; - } - - const sortedCategories = [...categoryMap.entries()].sort((a, b) => { - const posA = a[1][0]?.meta?.categoryPosition ?? 999; - const posB = b[1][0]?.meta?.categoryPosition ?? 999; - return posA - posB; - }); - - const roomLines = []; - for (const [catName, rooms] of sortedCategories) { - rooms.sort((a, b) => (a.meta?.position ?? 999) - (b.meta?.position ?? 999)); - for (const room of rooms) { - // Sort agents: claude-code first, codex second - room.agents.sort((a, b) => - a.agentType === b.agentType - ? 0 - : a.agentType === 'claude-code' - ? -1 - : 1, - ); - - const agentParts = room.agents.map((a) => { - const icon = STATUS_ICONS[a.status] || 'βšͺ'; - const label = getStatusLabel(a); - const tag = getAgentDisplayName(a.agentType); - return `${tag} ${icon} ${label}`; - }); - roomLines.push({ - category: catName, - categoryPosition: room.meta?.categoryPosition ?? 999, - position: room.meta?.position ?? 999, - line: ` **${room.name}** β€” ${agentParts.join(' | ')}`, - }); - } - } - - const header = `**πŸ“Š μ—μ΄μ „νŠΈ μƒνƒœ** β€” ν™œμ„± ${totalActive} / ${totalRooms}`; - const sections = renderCategorizedRoomSections({ - lines: roomLines, - showCategoryHeaders: channelMetaCache.size > 0, - }); - return `${header}\n\n${sections}`; -} - -// ── API Usage Fetchers ────────────────────────────────────────── - -interface ClaudeUsageData { - five_hour?: { utilization: number; resets_at: string }; - seven_day?: { utilization: number; resets_at: string }; -} - -interface CodexRateLimit { - limitId?: string; - limitName: string | null; - primary: { usedPercent: number; resetsAt: string | number }; - secondary: { usedPercent: number; resetsAt: string | number }; -} - -let usageApiBackoffUntil = 0; -let usageApi429Streak = 0; -let usageApiPollingDisabled = false; - -function parseRetryAfterMs(retryAfter: string | null): number | null { - if (!retryAfter) return null; - - const seconds = Number(retryAfter); - if (Number.isFinite(seconds) && seconds > 0) { - return seconds * 1000; - } - - const absolute = Date.parse(retryAfter); - if (!Number.isNaN(absolute)) { - return Math.max(0, absolute - Date.now()); - } - - return null; -} - -async function fetchClaudeUsage(): Promise { - if (usageApiPollingDisabled) { - logger.debug('Skipping usage API call (polling disabled for this process)'); - return null; - } - - // Skip if in backoff period (after 429) - if (Date.now() < usageApiBackoffUntil) { - logger.debug('Skipping usage API call (backoff active)'); - return null; - } - - try { - const envToken = readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN']); - let token = - process.env.CLAUDE_CODE_OAUTH_TOKEN || - envToken.CLAUDE_CODE_OAUTH_TOKEN || - ''; - if (!token) { - const configDir = - process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); - const credsPath = path.join(configDir, '.credentials.json'); - if (!fs.existsSync(credsPath)) return null; - const creds = JSON.parse(fs.readFileSync(credsPath, 'utf-8')); - token = creds?.claudeAiOauth?.accessToken || ''; - } - if (!token) return null; - - const res = await fetch('https://api.anthropic.com/api/oauth/usage', { - headers: { - Authorization: `Bearer ${token}`, - 'anthropic-beta': 'oauth-2025-04-20', - }, - }); - if (!res.ok) { - const body = await res.text().catch(() => ''); - if (res.status === 429) { - const retryAfter = res.headers.get('retry-after'); - const retryAfterMs = parseRetryAfterMs(retryAfter); - const backoffMs = Math.max(600_000, retryAfterMs ?? 0); - usageApi429Streak += 1; - usageApiBackoffUntil = Date.now() + backoffMs; - if (usageApi429Streak >= 3) { - usageApiPollingDisabled = true; - } - logger.warn( - { - status: 429, - retryAfter, - retryAfterMs, - backoffMs, - consecutive429s: usageApi429Streak, - pollingDisabled: usageApiPollingDisabled, - body: body.slice(0, 200), - }, - usageApiPollingDisabled - ? 'Usage API rate limited repeatedly (429), disabling usage polling for this process' - : 'Usage API rate limited (429), backing off', - ); - } else { - logger.warn( - { status: res.status, body: body.slice(0, 200) }, - 'Usage API returned non-OK status', - ); - } - return null; - } - usageApi429Streak = 0; - return (await res.json()) as ClaudeUsageData; - } catch (err) { - logger.debug({ err }, 'Usage API fetch failed'); - return null; - } -} - -async function fetchCodexUsage(): Promise { - // Find codex binary - const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin', 'codex'); - const codexBin = fs.existsSync(npmGlobalBin) ? npmGlobalBin : 'codex'; - - return new Promise((resolve) => { - let done = false; - const finish = (val: CodexRateLimit[] | null) => { - if (done) return; - done = true; - clearTimeout(timer); - try { - proc.kill(); - } catch { - /* ignore */ - } - resolve(val); - }; - - const timer = setTimeout(() => finish(null), 20000); - - let proc: ChildProcess; - try { - proc = spawn(codexBin, ['app-server'], { - stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...(process.env as Record), - PATH: [ - path.dirname(process.execPath), - path.join(os.homedir(), '.npm-global', 'bin'), - process.env.PATH || '', - ].join(':'), - }, - }); - } catch { - resolve(null); - return; - } - - proc.on('error', () => finish(null)); - proc.on('close', () => finish(null)); - - let buf = ''; - proc.stdout!.on('data', (chunk: Buffer) => { - buf += chunk.toString(); - const lines = buf.split('\n'); - buf = lines.pop() || ''; - for (const line of lines) { - if (!line.trim()) continue; - try { - const msg = JSON.parse(line); - if (msg.id === 1) { - // Initialize done, query rate limits - proc.stdin!.write( - JSON.stringify({ - jsonrpc: '2.0', - id: 2, - method: 'account/rateLimits/read', - params: {}, - }) + '\n', - ); - } else if (msg.id === 2 && msg.result) { - // Extract rate limits from rateLimitsByLimitId object - const byId = msg.result.rateLimitsByLimitId; - if (byId && typeof byId === 'object') { - finish(Object.values(byId) as CodexRateLimit[]); - } else { - finish(null); - } - } - } catch { - /* non-JSON line, skip */ - } - } - }); - - // Send initialize - proc.stdin!.write( - JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { clientInfo: { name: 'usage-monitor', version: '1.0' } }, - }) + '\n', - ); - }); -} - -// ── Usage Dashboard Builder ───────────────────────────────────── - -async function buildUsageContent(): Promise { - const lines: string[] = []; - const activeSnapshots = readStatusSnapshots(STATUS_SNAPSHOT_MAX_AGE_MS); - const hasActiveClaudeWork = activeSnapshots.some( - (snapshot) => - snapshot.agentType === 'claude-code' && - snapshot.entries.some( - (entry) => entry.status === 'processing' || entry.status === 'waiting', - ), - ); - const shouldFetchClaudeUsage = USAGE_DASHBOARD_ENABLED; - - const [liveClaudeUsage, codexUsage] = await Promise.all([ - shouldFetchClaudeUsage && !hasActiveClaudeWork - ? fetchClaudeUsage() - : Promise.resolve(null), - fetchCodexUsage(), - ]); - const claudeUsage = shouldFetchClaudeUsage - ? liveClaudeUsage || cachedClaudeUsageData - : null; - const claudeUsageIsCached = - shouldFetchClaudeUsage && !liveClaudeUsage && !!cachedClaudeUsageData; - if (shouldFetchClaudeUsage && liveClaudeUsage) { - cachedClaudeUsageData = liveClaudeUsage; - } - - const bar = (pct: number) => { - const filled = Math.round(pct / 10); - return 'β–ˆ'.repeat(filled) + 'β–‘'.repeat(10 - filled); - }; - - lines.push('πŸ“Š *μ‚¬μš©λŸ‰*'); - - type UsageRow = { - name: string; - h5pct: number; - h5reset: string; - d7pct: number; - d7reset: string; - }; - const rows: UsageRow[] = []; - - if (claudeUsage) { - const h5 = claudeUsage.five_hour; - const d7 = claudeUsage.seven_day; - rows.push({ - name: claudeUsageIsCached ? 'Claude*' : 'Claude', - h5pct: h5 - ? h5.utilization > 1 - ? Math.round(h5.utilization) - : Math.round(h5.utilization * 100) - : -1, - h5reset: h5 ? formatResetKST(h5.resets_at) : '', - d7pct: d7 - ? d7.utilization > 1 - ? Math.round(d7.utilization) - : Math.round(d7.utilization * 100) - : -1, - d7reset: d7 ? formatResetKST(d7.resets_at) : '', - }); - } - - if (codexUsage && Array.isArray(codexUsage)) { - const relevant = codexUsage.filter( - (limit) => - limit.primary.usedPercent > 0 || limit.secondary.usedPercent > 0, - ); - const display = relevant.length > 0 ? relevant : codexUsage.slice(0, 1); - for (const limit of display) { - rows.push({ - name: 'Codex', - h5pct: Math.round(limit.primary.usedPercent), - h5reset: formatResetKST(limit.primary.resetsAt), - d7pct: Math.round(limit.secondary.usedPercent), - d7reset: formatResetKST(limit.secondary.resetsAt), - }); - } - } - - if (rows.length > 0) { - lines.push('```'); - lines.push(' 5-Hour 7-Day'); - for (const row of rows) { - const h5 = - row.h5pct >= 0 - ? `${bar(row.h5pct)} ${String(row.h5pct).padStart(3)}%` - : ' β€” '; - const d7 = - row.d7pct >= 0 - ? `${bar(row.d7pct)} ${String(row.d7pct).padStart(3)}%` - : ' β€” '; - lines.push(`${row.name.padEnd(8)}${h5} ${d7}`); - } - lines.push('```'); - } else { - lines.push('_쑰회 λΆˆκ°€_'); - } - if (shouldFetchClaudeUsage && usageApiPollingDisabled) { - lines.push( - '_* Claude μ‚¬μš©λŸ‰ μ‘°νšŒλŠ” 반볡된 429둜 이번 ν”„λ‘œμ„ΈμŠ€μ—μ„œ μΌμ‹œ 쀑지_', - ); - } - if (claudeUsageIsCached) { - lines.push('_* Claude μ‚¬μš©λŸ‰μ€ μž‘μ—… 쀑일 λ•ŒλŠ” μΊμ‹œκ°’ μœ μ§€_'); - } - lines.push(''); - - lines.push('πŸ–₯️ *μ„œλ²„*'); - - const loadAvg = os.loadavg(); - const cpuCount = os.cpus().length; - const cpuPct = Math.round((loadAvg[1] / cpuCount) * 100); - - const totalMem = os.totalmem(); - const usedMem = totalMem - os.freemem(); - const memPct = Math.round((usedMem / totalMem) * 100); - const memUsedGB = (usedMem / 1073741824).toFixed(1); - const memTotalGB = (totalMem / 1073741824).toFixed(1); - - let diskPct = 0; - let diskUsedGB = '?'; - let diskTotalGB = '?'; - try { - const df = execSync('df -B1 / | tail -1', { - encoding: 'utf-8', - timeout: 5000, - }).trim(); - const parts = df.split(/\s+/); - const diskUsed = parseInt(parts[2], 10); - const diskTotal = parseInt(parts[1], 10); - diskPct = Math.round((diskUsed / diskTotal) * 100); - diskUsedGB = (diskUsed / 1073741824).toFixed(0); - diskTotalGB = (diskTotal / 1073741824).toFixed(0); - } catch { - /* ignore */ - } - - lines.push('```'); - lines.push(`${'CPU'.padEnd(8)}${bar(cpuPct)} ${String(cpuPct).padStart(3)}%`); - lines.push( - `${'Memory'.padEnd(8)}${bar(memPct)} ${String(memPct).padStart(3)}% ${memUsedGB}/${memTotalGB}GB`, - ); - lines.push( - `${'Disk'.padEnd(8)}${bar(diskPct)} ${String(diskPct).padStart(3)}% ${diskUsedGB}/${diskTotalGB}GB`, - ); - lines.push(`${'Uptime'.padEnd(8)}${formatElapsed(os.uptime() * 1000)}`); - lines.push('```'); - - return lines.join('\n'); -} - -// ── Unified Dashboard Lifecycle ────────────────────────────────── - -let usageUpdateInProgress = false; - -async function refreshUsageCache(): Promise { - if (usageUpdateInProgress) return; - usageUpdateInProgress = true; - try { - cachedUsageContent = await buildUsageContent(); - } catch { - /* keep previous cache */ - } finally { - usageUpdateInProgress = false; - } -} - -function buildUnifiedDashboard(): string { - const parts: string[] = []; - if (STATUS_SHOW_ROOMS) { - parts.push(buildStatusContent()); - } - if (cachedUsageContent) { - parts.push(cachedUsageContent); - } - return composeDashboardContent(parts); -} - -async function startStatusDashboard(): Promise { - if (!STATUS_CHANNEL_ID) return; - const isRenderer = SERVICE_AGENT_TYPE === 'claude-code'; - - const statusJid = `dc:${STATUS_CHANNEL_ID}`; - - const findDiscordChannel = () => - channels.find((c) => c.name.startsWith('discord') && c.isConnected()); - - // Initial usage fetch - if (isRenderer) { - await refreshUsageCache(); - } - - const updateStatus = async () => { - writeLocalStatusSnapshot(); - if (!isRenderer) return; - - const ch = findDiscordChannel(); - if (!ch) return; - - try { - await refreshChannelMeta(); - const content = buildUnifiedDashboard(); - if (!content) { - statusMessageId = null; - return; - } - - if (statusMessageId && ch.editMessage) { - await ch.editMessage(statusJid, statusMessageId, content); - } else if (ch.sendAndTrack) { - const id = await ch.sendAndTrack(statusJid, content); - if (id) statusMessageId = id; - } - } catch (err) { - logger.debug({ err }, 'Dashboard update failed'); - statusMessageId = null; - } - }; - - // Status updates every 10s - setInterval(updateStatus, STATUS_UPDATE_INTERVAL); - await updateStatus(); - - // Usage cache refreshes every 5min (only on renderer) - if (isRenderer) { - setInterval(refreshUsageCache, USAGE_UPDATE_INTERVAL); - } - - logger.info( - { channelId: STATUS_CHANNEL_ID, isRenderer, agentType: SERVICE_AGENT_TYPE }, - isRenderer - ? 'Unified dashboard started' - : 'Status snapshot updater started', - ); -} - -// Legacy compat β€” now handled inside startStatusDashboard -async function startUsageDashboard(): Promise { - // Usage is now integrated into the unified dashboard -} - async function announceRestartRecovery( processStartedAtMs: number, ): Promise { @@ -1171,18 +440,23 @@ async function main(): Promise { 'Queued interrupted group for restart recovery', ); } - // Purge old messages in status channel before creating fresh dashboards - if (STATUS_CHANNEL_ID && SERVICE_AGENT_TYPE === 'claude-code') { - const statusJid = `dc:${STATUS_CHANNEL_ID}`; - const ch = channels.find( - (c) => c.name.startsWith('discord') && c.isConnected() && c.purgeChannel, - ); - if (ch?.purgeChannel) { - await ch.purgeChannel(statusJid); - } - } - await startStatusDashboard(); - await startUsageDashboard(); + await startUnifiedDashboard({ + assistantName: ASSISTANT_NAME, + serviceAgentType: SERVICE_AGENT_TYPE, + statusChannelId: STATUS_CHANNEL_ID, + statusUpdateInterval: STATUS_UPDATE_INTERVAL, + usageUpdateInterval: USAGE_UPDATE_INTERVAL, + channels, + queue, + registeredGroups: () => registeredGroups, + onGroupNameSynced: (jid, name) => { + const group = registeredGroups[jid]; + if (group) { + group.name = name; + } + }, + purgeOnStart: true, + }); runtime.startMessageLoop().catch((err) => { logger.fatal({ err }, 'Message loop crashed unexpectedly'); process.exit(1); diff --git a/src/task-scheduler.test.ts b/src/task-scheduler.test.ts index b0871af..3a52a58 100644 --- a/src/task-scheduler.test.ts +++ b/src/task-scheduler.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { _initTestDatabase, createTask, getTaskById } from './db.js'; +import { createTaskStatusTracker } from './task-status-tracker.js'; +import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js'; import { _resetSchedulerLoopForTests, computeNextRun, @@ -274,6 +276,71 @@ Check the run. expect(rendered).not.toContain('- κ²½κ³Ό μ‹œκ°„:'); }); + it('edits the existing watcher status message with refreshed elapsed time', async () => { + vi.setSystemTime(new Date('2026-03-19T07:00:00.000Z')); + + createTask({ + id: 'task-watch-status', + group_folder: 'shared-group', + chat_jid: 'shared@g.us', + agent_type: 'codex', + prompt: ` +[BACKGROUND CI WATCH] + +Watch target: +GitHub Actions run 123456 + +Task ID: +task-watch-status + +Check instructions: +Check the run. + `.trim(), + schedule_type: 'interval', + schedule_value: '60000', + context_mode: 'isolated', + next_run: new Date(Date.now() - 60_000).toISOString(), + status: 'active', + created_at: '2026-02-22T00:00:00.000Z', + }); + + const sendTrackedMessage = vi.fn(async () => 'msg-123'); + const editTrackedMessage = vi.fn(async () => {}); + + const tracker = createTaskStatusTracker(getTaskById('task-watch-status')!, { + sendTrackedMessage, + editTrackedMessage, + }); + + await tracker.update('checking'); + + const firstState = getTaskById('task-watch-status'); + expect(sendTrackedMessage).toHaveBeenCalledWith( + 'shared@g.us', + expect.stringContaining(`${TASK_STATUS_MESSAGE_PREFIX}CI κ°μ‹œ 쀑:`), + ); + expect(firstState?.status_message_id).toBe('msg-123'); + expect(firstState?.status_started_at).toBe('2026-03-19T07:00:00.000Z'); + + vi.setSystemTime(new Date('2026-03-19T07:02:10.000Z')); + await tracker.update('waiting', '2026-03-19T07:04:10.000Z'); + + expect(editTrackedMessage).toHaveBeenCalledWith( + 'shared@g.us', + 'msg-123', + expect.stringContaining('- κ²½κ³Ό μ‹œκ°„: 2λΆ„ 10초'), + ); + expect(editTrackedMessage).toHaveBeenCalledWith( + 'shared@g.us', + 'msg-123', + expect.stringContaining('- λ‹€μŒ 확인: 16μ‹œ 04λΆ„ 10초'), + ); + + const secondState = getTaskById('task-watch-status'); + expect(secondState?.status_message_id).toBe('msg-123'); + expect(secondState?.status_started_at).toBe('2026-03-19T07:00:00.000Z'); + }); + it('computeNextRun anchors interval tasks to scheduled time to prevent drift', () => { const scheduledTime = new Date(Date.now() - 2000).toISOString(); // 2s ago const task = { diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts index 00d8792..2ab8f00 100644 --- a/src/task-scheduler.ts +++ b/src/task-scheduler.ts @@ -18,20 +18,14 @@ import { getDueTasks, getTaskById, logTaskRun, - updateTaskStatusTracking, updateTask, updateTaskAfterRun, } from './db.js'; import { GroupQueue } from './group-queue.js'; import { resolveGroupFolderPath } from './group-folder.js'; import { logger } from './logger.js'; -import { - getTaskQueueJid, - isWatchCiTask, - renderWatchCiStatusMessage, - TASK_STATUS_MESSAGE_PREFIX, - type WatcherStatusPhase, -} from './task-watch-status.js'; +import { createTaskStatusTracker } from './task-status-tracker.js'; +import { getTaskQueueJid } from './task-watch-status.js'; import { AgentType, RegisteredGroup, ScheduledTask } from './types.js'; export { extractWatchCiTarget, @@ -102,22 +96,90 @@ export interface SchedulerDependencies { ) => Promise; } +interface TaskExecutionContext { + group: RegisteredGroup; + groupDir: string; + isMain: boolean; + queueJid: string; + sessionId?: string; + taskAgentType: AgentType; +} + +function resolveTaskExecutionContext( + task: ScheduledTask, + deps: SchedulerDependencies, +): TaskExecutionContext { + const groupDir = resolveGroupFolderPath(task.group_folder); + fs.mkdirSync(groupDir, { recursive: true }); + + const groups = deps.registeredGroups(); + const group = Object.values(groups).find( + (registeredGroup) => registeredGroup.folder === task.group_folder, + ); + if (!group) { + throw new Error(`Group not found: ${task.group_folder}`); + } + + const isMain = group.isMain === true; + const taskAgentType = + task.agent_type || deps.serviceAgentType || SERVICE_AGENT_TYPE; + const sessions = deps.getSessions(); + + return { + group, + groupDir, + isMain, + queueJid: getTaskQueueJid(task), + sessionId: + task.context_mode === 'group' ? sessions[task.group_folder] : undefined, + taskAgentType, + }; +} + +function writeTaskSnapshotForGroup( + taskAgentType: AgentType, + groupFolder: string, + isMain: boolean, +): void { + const tasks = getAllTasks(taskAgentType); + writeTasksSnapshot( + groupFolder, + isMain, + tasks.map((task) => ({ + id: task.id, + groupFolder: task.group_folder, + prompt: task.prompt, + schedule_type: task.schedule_type, + schedule_value: task.schedule_value, + status: task.status, + next_run: task.next_run, + })), + ); +} + async function runTask( task: ScheduledTask, deps: SchedulerDependencies, ): Promise { const startTime = Date.now(); - let groupDir: string; + let context: TaskExecutionContext; try { - groupDir = resolveGroupFolderPath(task.group_folder); + context = resolveTaskExecutionContext(task, deps); } catch (err) { const error = err instanceof Error ? err.message : String(err); - // Stop retry churn for malformed legacy rows. - updateTask(task.id, { status: 'paused' }); - logger.error( - { taskId: task.id, groupFolder: task.group_folder, error }, - 'Task has invalid group folder', - ); + if (error.startsWith('Group not found:')) { + logger.error( + { taskId: task.id, groupFolder: task.group_folder, error }, + 'Group not found for task', + ); + } else { + // Stop retry churn for malformed legacy rows. + updateTask(task.id, { status: 'paused' }); + logger.error( + { taskId: task.id, groupFolder: task.group_folder, error }, + 'Task has invalid group folder', + ); + } logTaskRun({ task_id: task.id, run_at: new Date().toISOString(), @@ -128,134 +190,42 @@ async function runTask( }); return; } - fs.mkdirSync(groupDir, { recursive: true }); logger.info( { taskId: task.id, group: task.group_folder }, 'Running scheduled task', ); - const groups = deps.registeredGroups(); - const group = Object.values(groups).find( - (g) => g.folder === task.group_folder, - ); - - if (!group) { - logger.error( - { taskId: task.id, groupFolder: task.group_folder }, - 'Group not found for task', - ); - logTaskRun({ - task_id: task.id, - run_at: new Date().toISOString(), - duration_ms: Date.now() - startTime, - status: 'error', - result: null, - error: `Group not found: ${task.group_folder}`, - }); - return; - } - // Update tasks snapshot for agent to read (filtered by group) - const isMain = group.isMain === true; - const taskAgentType = - task.agent_type || deps.serviceAgentType || SERVICE_AGENT_TYPE; - const tasks = getAllTasks(taskAgentType); - writeTasksSnapshot( + writeTaskSnapshotForGroup( + context.taskAgentType, task.group_folder, - isMain, - tasks.map((t) => ({ - id: t.id, - groupFolder: t.group_folder, - prompt: t.prompt, - schedule_type: t.schedule_type, - schedule_value: t.schedule_value, - status: t.status, - next_run: t.next_run, - })), + context.isMain, ); let result: string | null = null; let error: string | null = null; - let statusMessageId = task.status_message_id; - let statusStartedAt = task.status_started_at; - const queueJid = getTaskQueueJid(task); - - // For group context mode, use the group's current session - const sessions = deps.getSessions(); - const sessionId = - task.context_mode === 'group' ? sessions[task.group_folder] : undefined; - - const shouldTrackStatus = - isWatchCiTask(task) && - typeof deps.sendTrackedMessage === 'function' && - typeof deps.editTrackedMessage === 'function'; - - const persistStatusTracking = () => { - const currentTask = getTaskById(task.id); - if (!currentTask) return; - updateTaskStatusTracking(task.id, { - status_message_id: statusMessageId, - status_started_at: statusStartedAt, - }); - }; - - const updateWatcherStatus = async ( - phase: WatcherStatusPhase, - nextRun?: string | null, - ) => { - if (!shouldTrackStatus) { - return; - } - - const checkedAt = new Date().toISOString(); - if (!statusStartedAt) { - statusStartedAt = checkedAt; - } - - const text = renderWatchCiStatusMessage({ - task, - phase, - checkedAt, - statusStartedAt, - nextRun, - }); - const payload = `${TASK_STATUS_MESSAGE_PREFIX}${text}`; - - if (statusMessageId) { - try { - await deps.editTrackedMessage!(task.chat_jid, statusMessageId, payload); - persistStatusTracking(); - return; - } catch { - statusMessageId = null; - persistStatusTracking(); - } - } - - const messageId = await deps.sendTrackedMessage!(task.chat_jid, payload); - if (messageId) { - statusMessageId = messageId; - persistStatusTracking(); - } - }; + const statusTracker = createTaskStatusTracker(task, { + sendTrackedMessage: deps.sendTrackedMessage, + editTrackedMessage: deps.editTrackedMessage, + }); try { - await updateWatcherStatus('checking'); + await statusTracker.update('checking'); const output = await runAgentProcess( - group, + context.group, { prompt: task.prompt, - sessionId, + sessionId: context.sessionId, groupFolder: task.group_folder, chatJid: task.chat_jid, - isMain, + isMain: context.isMain, isScheduledTask: true, assistantName: ASSISTANT_NAME, }, (proc, processName) => - deps.onProcess(queueJid, proc, processName, task.group_folder), + deps.onProcess(context.queueJid, proc, processName, task.group_folder), async (streamedOutput: AgentOutput) => { if (streamedOutput.phase === 'progress') { return; @@ -281,7 +251,7 @@ async function runTask( logger.info( { taskId: task.id, - agentType: taskAgentType, + agentType: context.taskAgentType, durationMs: Date.now() - startTime, }, 'Task completed', @@ -296,7 +266,7 @@ async function runTask( const nextRun = currentTask ? computeNextRun(task) : null; if (!currentTask) { - await updateWatcherStatus('completed'); + await statusTracker.update('completed'); logger.debug( { taskId: task.id }, 'Task deleted during execution, skipping persistence', @@ -305,11 +275,11 @@ async function runTask( } if (error) { - await updateWatcherStatus('retrying', nextRun); + await statusTracker.update('retrying', nextRun); } else if (nextRun) { - await updateWatcherStatus('waiting', nextRun); + await statusTracker.update('waiting', nextRun); } else { - await updateWatcherStatus('completed'); + await statusTracker.update('completed'); } logTaskRun({ diff --git a/src/task-status-tracker.ts b/src/task-status-tracker.ts new file mode 100644 index 0000000..6e2691e --- /dev/null +++ b/src/task-status-tracker.ts @@ -0,0 +1,92 @@ +import { getTaskById, updateTaskStatusTracking } from './db.js'; +import { + isWatchCiTask, + renderWatchCiStatusMessage, + TASK_STATUS_MESSAGE_PREFIX, + type WatcherStatusPhase, +} from './task-watch-status.js'; +import type { ScheduledTask } from './types.js'; + +export interface TaskStatusTrackerTransport { + sendTrackedMessage?: (jid: string, text: string) => Promise; + editTrackedMessage?: ( + jid: string, + messageId: string, + text: string, + ) => Promise; +} + +export interface TaskStatusTracker { + enabled: boolean; + update: ( + phase: WatcherStatusPhase, + nextRun?: string | null, + ) => Promise; +} + +export function createTaskStatusTracker( + task: ScheduledTask, + transport: TaskStatusTrackerTransport, +): TaskStatusTracker { + let statusMessageId = task.status_message_id; + let statusStartedAt = task.status_started_at; + const enabled = + isWatchCiTask(task) && + typeof transport.sendTrackedMessage === 'function' && + typeof transport.editTrackedMessage === 'function'; + + const persist = () => { + const currentTask = getTaskById(task.id); + if (!currentTask) return; + updateTaskStatusTracking(task.id, { + status_message_id: statusMessageId, + status_started_at: statusStartedAt, + }); + }; + + const update = async ( + phase: WatcherStatusPhase, + nextRun?: string | null, + ) => { + if (!enabled) return; + + const checkedAt = new Date().toISOString(); + if (!statusStartedAt) { + statusStartedAt = checkedAt; + } + + const payload = `${TASK_STATUS_MESSAGE_PREFIX}${renderWatchCiStatusMessage({ + task, + phase, + checkedAt, + statusStartedAt, + nextRun, + })}`; + + if (statusMessageId) { + try { + await transport.editTrackedMessage!( + task.chat_jid, + statusMessageId, + payload, + ); + persist(); + return; + } catch { + statusMessageId = null; + persist(); + } + } + + const nextMessageId = await transport.sendTrackedMessage!( + task.chat_jid, + payload, + ); + if (nextMessageId) { + statusMessageId = nextMessageId; + persist(); + } + }; + + return { enabled, update }; +} diff --git a/src/unified-dashboard.ts b/src/unified-dashboard.ts new file mode 100644 index 0000000..5d1c531 --- /dev/null +++ b/src/unified-dashboard.ts @@ -0,0 +1,742 @@ +import { ChildProcess, execSync, spawn } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + STATUS_SHOW_ROOMS, + USAGE_DASHBOARD_ENABLED, +} from './config.js'; +import { + fetchClaudeUsageViaCli, + type ClaudeUsageData, +} from './claude-usage.js'; +import { + composeDashboardContent, + formatElapsed, + getStatusLabel as formatDashboardStatusLabel, + type DashboardRoomLine, + renderCategorizedRoomSections, +} from './dashboard-render.js'; +import { getAllChats, updateRegisteredGroupName } from './db.js'; +import { readEnvFile } from './env.js'; +import type { GroupQueue } from './group-queue.js'; +import { logger } from './logger.js'; +import { + readStatusSnapshots, + writeStatusSnapshot, +} from './status-dashboard.js'; +import type { + AgentType, + Channel, + ChannelMeta, + RegisteredGroup, +} from './types.js'; + +export interface UnifiedDashboardOptions { + assistantName: string; + serviceAgentType: AgentType; + statusChannelId: string; + statusUpdateInterval: number; + usageUpdateInterval: number; + channels: Channel[]; + queue: GroupQueue; + registeredGroups: () => Record; + onGroupNameSynced?: (jid: string, name: string) => void; + purgeOnStart?: boolean; +} + +interface CodexRateLimit { + limitId?: string; + limitName: string | null; + primary: { usedPercent: number; resetsAt: string | number }; + secondary: { usedPercent: number; resetsAt: string | number }; +} + +const STATUS_ICONS: Record = { + processing: '🟑', + waiting: 'πŸ”΅', + inactive: 'βšͺ', +}; + +const CHANNEL_META_REFRESH_MS = 300000; +const STATUS_SNAPSHOT_MAX_AGE_MS = 60000; + +let statusMessageId: string | null = null; +let cachedUsageContent = ''; +let cachedClaudeUsageData: ClaudeUsageData | null = null; +let usageUpdateInProgress = false; +let usageApiBackoffUntil = 0; +let usageApi429Streak = 0; +let usageApiPollingDisabled = false; +let channelMetaCache = new Map(); +let channelMetaLastRefresh = 0; + +function formatResetKST(value: string | number): string { + try { + const date = + typeof value === 'number' ? new Date(value * 1000) : new Date(value); + return date.toLocaleString('ko-KR', { + timeZone: 'Asia/Seoul', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return String(value); + } +} + +function findDiscordChannel(channels: Channel[]): Channel | undefined { + return channels.find( + (channel) => channel.name.startsWith('discord') && channel.isConnected(), + ); +} + +async function purgeDashboardChannel( + opts: Pick, +): Promise { + if (!opts.statusChannelId) return; + + const statusJid = `dc:${opts.statusChannelId}`; + const channel = opts.channels.find( + (item) => + item.name.startsWith('discord') && + item.isConnected() && + item.purgeChannel, + ); + if (channel?.purgeChannel) { + await channel.purgeChannel(statusJid); + } +} + +function parseRetryAfterMs(retryAfter: string | null): number | null { + if (!retryAfter) return null; + + const seconds = Number(retryAfter); + if (Number.isFinite(seconds) && seconds > 0) { + return seconds * 1000; + } + + const absolute = Date.parse(retryAfter); + if (!Number.isNaN(absolute)) { + return Math.max(0, absolute - Date.now()); + } + + return null; +} + +async function refreshChannelMeta( + opts: UnifiedDashboardOptions, +): Promise { + const now = Date.now(); + if (now - channelMetaLastRefresh < CHANNEL_META_REFRESH_MS) return; + + const channel = opts.channels.find( + (item) => + item.name.startsWith('discord') && item.isConnected() && item.getChannelMeta, + ); + if (!channel?.getChannelMeta) return; + + const localJids = Object.keys(opts.registeredGroups()).filter((jid) => + jid.startsWith('dc:'), + ); + const snapshotJids = readStatusSnapshots(STATUS_SNAPSHOT_MAX_AGE_MS) + .flatMap((snapshot) => snapshot.entries.map((entry) => entry.jid)) + .filter((jid) => jid.startsWith('dc:')); + const jids = [...new Set([...localJids, ...snapshotJids])]; + + try { + channelMetaCache = await channel.getChannelMeta(jids); + channelMetaLastRefresh = now; + + for (const [jid, meta] of channelMetaCache) { + if (!meta.name) continue; + const group = opts.registeredGroups()[jid]; + if (!group || group.name === meta.name) continue; + logger.info( + { jid, oldName: group.name, newName: meta.name }, + 'Syncing group name to Discord channel name', + ); + updateRegisteredGroupName(jid, meta.name); + opts.onGroupNameSynced?.(jid, meta.name); + } + } catch (err) { + logger.debug({ err }, 'Failed to refresh channel metadata'); + } +} + +function getAgentDisplayName(agentType: 'claude-code' | 'codex'): string { + return agentType === 'codex' ? 'μ½”λ±μŠ€' : '클코'; +} + +function formatRoomName( + jid: string, + meta: ChannelMeta | undefined, + fallbackName: string | undefined, + chatName: string | undefined, +): string { + const base = + meta?.name || + (chatName && chatName !== jid ? chatName : undefined) || + (fallbackName && fallbackName !== jid ? fallbackName : undefined) || + jid; + + if (jid.startsWith('dc:') && base !== jid && !base.startsWith('#')) { + return `#${base}`; + } + return base; +} + +function writeLocalStatusSnapshot(opts: UnifiedDashboardOptions): void { + const groups = opts.registeredGroups(); + const statuses = opts.queue.getStatuses(Object.keys(groups)); + + writeStatusSnapshot({ + agentType: opts.serviceAgentType, + assistantName: opts.assistantName, + updatedAt: new Date().toISOString(), + entries: statuses + .map((status) => { + const group = groups[status.jid]; + if (!group) return null; + return { + jid: status.jid, + name: group.name, + folder: group.folder, + agentType: (group.agentType || opts.serviceAgentType) as + | 'claude-code' + | 'codex', + status: status.status, + elapsedMs: status.elapsedMs, + pendingMessages: status.pendingMessages, + pendingTasks: status.pendingTasks, + }; + }) + .filter(Boolean) as Array<{ + jid: string; + name: string; + folder: string; + agentType: 'claude-code' | 'codex'; + status: 'processing' | 'waiting' | 'inactive'; + elapsedMs: number | null; + pendingMessages: boolean; + pendingTasks: number; + }>, + }); +} + +function buildStatusContent(): string { + if (!STATUS_SHOW_ROOMS) return ''; + + const snapshots = readStatusSnapshots(STATUS_SNAPSHOT_MAX_AGE_MS); + const chatNameByJid = new Map( + getAllChats().map((chat) => [chat.jid, chat.name]), + ); + + interface RoomEntry { + agentType: 'claude-code' | 'codex'; + status: 'processing' | 'waiting' | 'inactive'; + elapsedMs: number | null; + pendingMessages: boolean; + pendingTasks: number; + name: string; + meta: ChannelMeta | undefined; + } + + const byJid = new Map(); + + for (const snapshot of snapshots) { + const agentType = snapshot.agentType as 'claude-code' | 'codex'; + for (const entry of snapshot.entries) { + const existing = byJid.get(entry.jid) || []; + existing.push({ + agentType, + status: entry.status, + elapsedMs: entry.elapsedMs, + pendingMessages: entry.pendingMessages, + pendingTasks: entry.pendingTasks, + name: entry.name, + meta: channelMetaCache.get(entry.jid), + }); + byJid.set(entry.jid, existing); + } + } + + interface RoomInfo { + name: string; + meta: ChannelMeta | undefined; + agents: RoomEntry[]; + } + + const categoryMap = new Map(); + let totalActive = 0; + let totalRooms = 0; + + for (const [jid, agents] of byJid) { + const meta = agents[0]?.meta; + const category = meta?.category || '기타'; + if (!categoryMap.has(category)) { + categoryMap.set(category, []); + } + categoryMap.get(category)!.push({ + name: formatRoomName( + jid, + meta, + agents.find((agent) => agent.name && agent.name !== jid)?.name, + chatNameByJid.get(jid), + ), + meta, + agents, + }); + totalRooms++; + if (agents.some((agent) => agent.status === 'processing')) { + totalActive++; + } + } + + const sortedCategories = [...categoryMap.entries()].sort((a, b) => { + const posA = a[1][0]?.meta?.categoryPosition ?? 999; + const posB = b[1][0]?.meta?.categoryPosition ?? 999; + return posA - posB; + }); + + const roomLines: DashboardRoomLine[] = []; + for (const [categoryName, rooms] of sortedCategories) { + rooms.sort((a, b) => (a.meta?.position ?? 999) - (b.meta?.position ?? 999)); + for (const room of rooms) { + room.agents.sort((a, b) => + a.agentType === b.agentType ? 0 : a.agentType === 'claude-code' ? -1 : 1, + ); + const agentParts = room.agents.map((agent) => { + const icon = STATUS_ICONS[agent.status] || 'βšͺ'; + const label = formatDashboardStatusLabel({ + status: agent.status, + elapsedMs: agent.elapsedMs, + pendingTasks: agent.pendingTasks, + }); + const tag = getAgentDisplayName(agent.agentType); + return `${tag} ${icon} ${label}`; + }); + roomLines.push({ + category: categoryName, + categoryPosition: room.meta?.categoryPosition ?? 999, + position: room.meta?.position ?? 999, + line: ` **${room.name}** β€” ${agentParts.join(' | ')}`, + }); + } + } + + const header = `**πŸ“Š μ—μ΄μ „νŠΈ μƒνƒœ** β€” ν™œμ„± ${totalActive} / ${totalRooms}`; + const sections = renderCategorizedRoomSections({ + lines: roomLines, + showCategoryHeaders: channelMetaCache.size > 0, + }); + return `${header}\n\n${sections}`; +} + +async function fetchClaudeUsage(): Promise { + if (usageApiPollingDisabled) { + logger.debug('Skipping usage API call (polling disabled for this process)'); + return null; + } + if (Date.now() < usageApiBackoffUntil) { + logger.debug('Skipping usage API call (backoff active)'); + return null; + } + + const cliUsage = await fetchClaudeUsageViaCli(); + if (cliUsage) { + usageApi429Streak = 0; + return cliUsage; + } + + try { + const envToken = readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN']); + let token = + process.env.CLAUDE_CODE_OAUTH_TOKEN || + envToken.CLAUDE_CODE_OAUTH_TOKEN || + ''; + if (!token) { + const configDir = + process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); + const credsPath = path.join(configDir, '.credentials.json'); + if (!fs.existsSync(credsPath)) return null; + const creds = JSON.parse(fs.readFileSync(credsPath, 'utf-8')); + token = creds?.claudeAiOauth?.accessToken || ''; + } + if (!token) return null; + + const res = await fetch('https://api.anthropic.com/api/oauth/usage', { + headers: { + Authorization: `Bearer ${token}`, + 'anthropic-beta': 'oauth-2025-04-20', + }, + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + if (res.status === 429) { + const retryAfter = res.headers.get('retry-after'); + const retryAfterMs = parseRetryAfterMs(retryAfter); + const backoffMs = Math.max(600_000, retryAfterMs ?? 0); + usageApi429Streak += 1; + usageApiBackoffUntil = Date.now() + backoffMs; + if (usageApi429Streak >= 3) { + usageApiPollingDisabled = true; + } + logger.warn( + { + status: 429, + retryAfter, + retryAfterMs, + backoffMs, + consecutive429s: usageApi429Streak, + pollingDisabled: usageApiPollingDisabled, + body: body.slice(0, 200), + }, + usageApiPollingDisabled + ? 'Usage API rate limited repeatedly (429), disabling usage polling for this process' + : 'Usage API rate limited (429), backing off', + ); + } else { + logger.warn( + { status: res.status, body: body.slice(0, 200) }, + 'Usage API returned non-OK status', + ); + } + return null; + } + usageApi429Streak = 0; + return (await res.json()) as ClaudeUsageData; + } catch (err) { + logger.debug({ err }, 'Usage API fetch failed'); + return null; + } +} + +async function fetchCodexUsage(): Promise { + const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin', 'codex'); + const codexBin = fs.existsSync(npmGlobalBin) ? npmGlobalBin : 'codex'; + + return new Promise((resolve) => { + let done = false; + let proc: ChildProcess | null = null; + const finish = (value: CodexRateLimit[] | null) => { + if (done) return; + done = true; + clearTimeout(timer); + if (proc) { + try { + proc.kill(); + } catch { + /* ignore */ + } + } + resolve(value); + }; + + const timer = setTimeout(() => finish(null), 20_000); + + try { + proc = spawn(codexBin, ['app-server'], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { + ...(process.env as Record), + PATH: [ + path.dirname(process.execPath), + path.join(os.homedir(), '.npm-global', 'bin'), + process.env.PATH || '', + ].join(':'), + }, + }); + } catch { + resolve(null); + return; + } + + if (!proc.stdout || !proc.stdin) { + finish(null); + return; + } + + proc.on('error', () => finish(null)); + proc.on('close', () => finish(null)); + + let buffer = ''; + proc.stdout.on('data', (chunk: Buffer) => { + buffer += chunk.toString(); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + if (!line.trim()) continue; + try { + const message = JSON.parse(line); + if (message.id === 1) { + proc!.stdin!.write( + JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'account/rateLimits/read', + params: {}, + }) + '\n', + ); + } else if (message.id === 2 && message.result) { + const byId = message.result.rateLimitsByLimitId; + finish( + byId && typeof byId === 'object' + ? (Object.values(byId) as CodexRateLimit[]) + : null, + ); + } + } catch { + /* ignore */ + } + } + }); + + proc.stdin.write( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { clientInfo: { name: 'usage-monitor', version: '1.0' } }, + }) + '\n', + ); + }); +} + +async function buildUsageContent(): Promise { + const activeSnapshots = readStatusSnapshots(STATUS_SNAPSHOT_MAX_AGE_MS); + const hasActiveClaudeWork = activeSnapshots.some( + (snapshot) => + snapshot.agentType === 'claude-code' && + snapshot.entries.some( + (entry) => entry.status === 'processing' || entry.status === 'waiting', + ), + ); + const shouldFetchClaudeUsage = USAGE_DASHBOARD_ENABLED; + + const [liveClaudeUsage, codexUsage] = await Promise.all([ + shouldFetchClaudeUsage && !hasActiveClaudeWork + ? fetchClaudeUsage() + : Promise.resolve(null), + fetchCodexUsage(), + ]); + + const claudeUsage = shouldFetchClaudeUsage + ? liveClaudeUsage || cachedClaudeUsageData + : null; + const claudeUsageIsCached = + shouldFetchClaudeUsage && !liveClaudeUsage && !!cachedClaudeUsageData; + if (shouldFetchClaudeUsage && liveClaudeUsage) { + cachedClaudeUsageData = liveClaudeUsage; + } + + const lines: string[] = ['πŸ“Š *μ‚¬μš©λŸ‰*']; + const bar = (pct: number) => { + const filled = Math.round(pct / 10); + return 'β–ˆ'.repeat(filled) + 'β–‘'.repeat(10 - filled); + }; + + type UsageRow = { + name: string; + h5pct: number; + h5reset: string; + d7pct: number; + d7reset: string; + }; + const rows: UsageRow[] = []; + + if (claudeUsage) { + const h5 = claudeUsage.five_hour; + const d7 = claudeUsage.seven_day; + rows.push({ + name: claudeUsageIsCached ? 'Claude*' : 'Claude', + h5pct: h5 + ? h5.utilization > 1 + ? Math.round(h5.utilization) + : Math.round(h5.utilization * 100) + : -1, + h5reset: h5 ? formatResetKST(h5.resets_at) : '', + d7pct: d7 + ? d7.utilization > 1 + ? Math.round(d7.utilization) + : Math.round(d7.utilization * 100) + : -1, + d7reset: d7 ? formatResetKST(d7.resets_at) : '', + }); + } + + if (codexUsage && Array.isArray(codexUsage)) { + const relevant = codexUsage.filter( + (limit) => + limit.primary.usedPercent > 0 || limit.secondary.usedPercent > 0, + ); + const display = relevant.length > 0 ? relevant : codexUsage.slice(0, 1); + for (const limit of display) { + rows.push({ + name: 'Codex', + h5pct: Math.round(limit.primary.usedPercent), + h5reset: formatResetKST(limit.primary.resetsAt), + d7pct: Math.round(limit.secondary.usedPercent), + d7reset: formatResetKST(limit.secondary.resetsAt), + }); + } + } + + if (rows.length > 0) { + lines.push('```'); + lines.push(' 5-Hour 7-Day'); + for (const row of rows) { + const h5 = + row.h5pct >= 0 + ? `${bar(row.h5pct)} ${String(row.h5pct).padStart(3)}%` + : ' β€” '; + const d7 = + row.d7pct >= 0 + ? `${bar(row.d7pct)} ${String(row.d7pct).padStart(3)}%` + : ' β€” '; + lines.push(`${row.name.padEnd(8)}${h5} ${d7}`); + } + lines.push('```'); + } else { + lines.push('_쑰회 λΆˆκ°€_'); + } + + if (shouldFetchClaudeUsage && usageApiPollingDisabled) { + lines.push( + '_* Claude μ‚¬μš©λŸ‰ μ‘°νšŒλŠ” 반볡된 429둜 이번 ν”„λ‘œμ„ΈμŠ€μ—μ„œ μΌμ‹œ 쀑지_', + ); + } + if (claudeUsageIsCached) { + lines.push('_* Claude μ‚¬μš©λŸ‰μ€ μž‘μ—… 쀑일 λ•ŒλŠ” μΊμ‹œκ°’ μœ μ§€_'); + } + lines.push(''); + lines.push('πŸ–₯️ *μ„œλ²„*'); + + const loadAvg = os.loadavg(); + const cpuCount = os.cpus().length; + const cpuPct = Math.round((loadAvg[1] / cpuCount) * 100); + const totalMem = os.totalmem(); + const usedMem = totalMem - os.freemem(); + const memPct = Math.round((usedMem / totalMem) * 100); + const memUsedGB = (usedMem / 1073741824).toFixed(1); + const memTotalGB = (totalMem / 1073741824).toFixed(1); + + let diskPct = 0; + let diskUsedGB = '?'; + let diskTotalGB = '?'; + try { + const df = execSync('df -B1 / | tail -1', { + encoding: 'utf-8', + timeout: 5000, + }).trim(); + const parts = df.split(/\s+/); + const diskUsed = parseInt(parts[2], 10); + const diskTotal = parseInt(parts[1], 10); + diskPct = Math.round((diskUsed / diskTotal) * 100); + diskUsedGB = (diskUsed / 1073741824).toFixed(0); + diskTotalGB = (diskTotal / 1073741824).toFixed(0); + } catch { + /* ignore */ + } + + lines.push('```'); + lines.push(`${'CPU'.padEnd(8)}${bar(cpuPct)} ${String(cpuPct).padStart(3)}%`); + lines.push( + `${'Memory'.padEnd(8)}${bar(memPct)} ${String(memPct).padStart(3)}% ${memUsedGB}/${memTotalGB}GB`, + ); + lines.push( + `${'Disk'.padEnd(8)}${bar(diskPct)} ${String(diskPct).padStart(3)}% ${diskUsedGB}/${diskTotalGB}GB`, + ); + lines.push(`${'Uptime'.padEnd(8)}${formatElapsed(os.uptime() * 1000)}`); + lines.push('```'); + + return lines.join('\n'); +} + +function buildUnifiedDashboardContent(): string { + const sections: string[] = []; + if (STATUS_SHOW_ROOMS) { + sections.push(buildStatusContent()); + } + if (cachedUsageContent) { + sections.push(cachedUsageContent); + } + return composeDashboardContent(sections); +} + +async function refreshUsageCache(): Promise { + if (usageUpdateInProgress) return; + usageUpdateInProgress = true; + try { + cachedUsageContent = await buildUsageContent(); + } catch { + /* keep previous cache */ + } finally { + usageUpdateInProgress = false; + } +} + +export async function startUnifiedDashboard( + opts: UnifiedDashboardOptions, +): Promise { + if (!opts.statusChannelId) return; + + const isRenderer = opts.serviceAgentType === 'claude-code'; + const statusJid = `dc:${opts.statusChannelId}`; + + if (isRenderer && opts.purgeOnStart) { + await purgeDashboardChannel(opts); + } + + if (isRenderer) { + await refreshUsageCache(); + } + + const updateStatus = async () => { + writeLocalStatusSnapshot(opts); + if (!isRenderer) return; + + const channel = findDiscordChannel(opts.channels); + if (!channel) return; + + try { + await refreshChannelMeta(opts); + const content = buildUnifiedDashboardContent(); + if (!content) { + statusMessageId = null; + return; + } + + if (statusMessageId && channel.editMessage) { + await channel.editMessage(statusJid, statusMessageId, content); + } else if (channel.sendAndTrack) { + const id = await channel.sendAndTrack(statusJid, content); + if (id) statusMessageId = id; + } + } catch (err) { + logger.debug({ err }, 'Dashboard update failed'); + statusMessageId = null; + } + }; + + setInterval(updateStatus, opts.statusUpdateInterval); + await updateStatus(); + + if (isRenderer) { + setInterval(refreshUsageCache, opts.usageUpdateInterval); + } + + logger.info( + { + channelId: opts.statusChannelId, + isRenderer, + agentType: opts.serviceAgentType, + }, + isRenderer + ? 'Unified dashboard started' + : 'Status snapshot updater started', + ); +}