refactor: extract dashboard and task helpers

This commit is contained in:
Eyejoker
2026-03-23 06:59:59 +09:00
parent 866d4970f3
commit a2db8f6c0e
7 changed files with 1358 additions and 1626 deletions

View File

@@ -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<string, string>;
}): Record<string, string> {
const cleanEnv = { ...(process.env as Record<string, string>) };
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<string, string>;
envVars: Record<string, string>;
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<string, string>;
envVars: Record<string, string>;
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<string, string>) };
// 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<string, string> = {
...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 };

View File

@@ -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<string, string> = {
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<string, ChannelMeta>();
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<void> {
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<string, typeof entries>();
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<ClaudeUsageData | null> {
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<CodexRateLimit[] | null> {
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<string, string>),
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<string> {
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<void> {
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<void> {
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<void> {
// Usage dashboard is integrated into startStatusDashboard via unified dashboard.
}

View File

@@ -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<string, string> = {
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<string, ChannelMeta>();
let channelMetaLastRefresh = 0;
const CHANNEL_META_REFRESH_MS = 300000; // 5 minutes
const STATUS_SNAPSHOT_MAX_AGE_MS = 60000;
async function refreshChannelMeta(): Promise<void> {
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<string, RoomEntry[]>();
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<string, RoomInfo[]>();
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<ClaudeUsageData | null> {
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<CodexRateLimit[] | null> {
// 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<string, string>),
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<string> {
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<void> {
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<void> {
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<void> {
// Usage is now integrated into the unified dashboard
}
async function announceRestartRecovery(
processStartedAtMs: number,
): Promise<RestartContext | null> {
@@ -1171,18 +440,23 @@ async function main(): Promise<void> {
'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);

View File

@@ -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 = {

View File

@@ -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<void>;
}
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<void> {
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({

View File

@@ -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<string | null>;
editTrackedMessage?: (
jid: string,
messageId: string,
text: string,
) => Promise<void>;
}
export interface TaskStatusTracker {
enabled: boolean;
update: (
phase: WatcherStatusPhase,
nextRun?: string | null,
) => Promise<void>;
}
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 };
}

742
src/unified-dashboard.ts Normal file
View File

@@ -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<string, RegisteredGroup>;
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<string, string> = {
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<string, ChannelMeta>();
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<UnifiedDashboardOptions, 'channels' | 'statusChannelId'>,
): Promise<void> {
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<void> {
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<string, RoomEntry[]>();
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<string, RoomInfo[]>();
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<ClaudeUsageData | null> {
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<CodexRateLimit[] | null> {
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<string, string>),
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<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 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<void> {
if (usageUpdateInProgress) return;
usageUpdateInProgress = true;
try {
cachedUsageContent = await buildUsageContent();
} catch {
/* keep previous cache */
} finally {
usageUpdateInProgress = false;
}
}
export async function startUnifiedDashboard(
opts: UnifiedDashboardOptions,
): Promise<void> {
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',
);
}