912 lines
26 KiB
TypeScript
912 lines
26 KiB
TypeScript
import { execSync } from 'child_process';
|
|
import fs from 'fs';
|
|
import os from 'os';
|
|
|
|
import {
|
|
ARBITER_AGENT_TYPE,
|
|
ARBITER_MODEL_CONFIG,
|
|
DEFAULT_CLAUDE_MODEL,
|
|
DEFAULT_CODEX_MODEL,
|
|
OWNER_AGENT_TYPE,
|
|
OWNER_MODEL_CONFIG,
|
|
REVIEWER_AGENT_TYPE,
|
|
REVIEWER_MODEL_CONFIG,
|
|
STATUS_SHOW_ROOM_DETAILS,
|
|
STATUS_SHOW_ROOMS,
|
|
USAGE_DASHBOARD_ENABLED,
|
|
CODEX_WARMUP_CONFIG,
|
|
getMoaConfig,
|
|
} from './config.js';
|
|
import {
|
|
fetchKimiUsage,
|
|
buildKimiUsageRows,
|
|
type KimiUsageData,
|
|
} from './kimi-usage.js';
|
|
import { getGlobalFailoverInfo } from './service-routing.js';
|
|
import {
|
|
fetchAllClaudeUsage,
|
|
fetchAllClaudeProfiles,
|
|
type ClaudeAccountUsage,
|
|
} from './claude-usage.js';
|
|
import {
|
|
CODEX_FULL_SCAN_INTERVAL,
|
|
refreshActiveCodexUsage,
|
|
refreshAllCodexAccountUsage,
|
|
} from './codex-usage-collector.js';
|
|
import { runCodexWarmupCycle } from './codex-warmup.js';
|
|
import { evaluateAndApplyAutoPairedMode } from './auto-paired-mode.js';
|
|
import {
|
|
composeDashboardContent,
|
|
formatElapsed,
|
|
getStatusLabel as formatDashboardStatusLabel,
|
|
type DashboardRoomLine,
|
|
renderCategorizedRoomSections,
|
|
} from './dashboard-render.js';
|
|
import {
|
|
buildClaudeUsageRows,
|
|
extractCodexUsageRows,
|
|
mergeClaudeDashboardAccounts,
|
|
type UsageRow,
|
|
} from './dashboard-usage-rows.js';
|
|
import { getAllChats, getAllTasks, updateRegisteredGroupName } from './db.js';
|
|
import type { GroupQueue } from './group-queue.js';
|
|
import { logger } from './logger.js';
|
|
import { isWatchCiTask } from './task-watch-status.js';
|
|
import {
|
|
readStatusSnapshots,
|
|
writeStatusSnapshot,
|
|
} from './status-dashboard.js';
|
|
import type {
|
|
AgentType,
|
|
Channel,
|
|
ChannelMeta,
|
|
RegisteredGroup,
|
|
ScheduledTask,
|
|
} from './types.js';
|
|
|
|
export interface UnifiedDashboardOptions {
|
|
assistantName: string;
|
|
serviceId: string;
|
|
serviceAgentType: AgentType;
|
|
statusChannelId: string;
|
|
statusUpdateInterval: number;
|
|
usageUpdateInterval: number;
|
|
channels: Channel[];
|
|
queue: GroupQueue;
|
|
roomBindings: () => Record<string, RegisteredGroup>;
|
|
onGroupNameSynced?: (jid: string, name: string) => void;
|
|
purgeOnStart?: boolean;
|
|
}
|
|
|
|
const STATUS_ICONS: Record<string, string> = {
|
|
processing: '🟡',
|
|
waiting: '🔵',
|
|
inactive: '⚪',
|
|
};
|
|
|
|
const CHANNEL_META_REFRESH_MS = 300000;
|
|
const STATUS_SNAPSHOT_MAX_AGE_MS = 60000;
|
|
/** Usage data can be up to 10 min old before considered stale. */
|
|
const USAGE_SNAPSHOT_MAX_AGE_MS = 600_000;
|
|
/**
|
|
* Renderer refreshes usage cache every 30s (not 5min).
|
|
* Claude API calls are internally rate-limited to 5min per token,
|
|
* so this only affects how quickly Codex snapshot data is picked up.
|
|
*/
|
|
const RENDERER_USAGE_REFRESH_MS = 30_000;
|
|
|
|
let statusMessageId: string | null = null;
|
|
let cachedUsageContent = '';
|
|
let cachedClaudeAccounts: ClaudeAccountUsage[] = [];
|
|
let cachedKimiUsage: KimiUsageData | null = null;
|
|
let usageUpdateInProgress = false;
|
|
let channelMetaCache = new Map<string, ChannelMeta>();
|
|
let channelMetaLastRefresh = 0;
|
|
let dashboardUpdateLogged = false;
|
|
/** Codex service only: cached usage rows written into the status snapshot. */
|
|
let cachedCodexUsageRows: UsageRow[] = [];
|
|
/** Codex service only: ISO timestamp of last successful usage fetch. */
|
|
let codexUsageFetchedAt: string | null = null;
|
|
/** Renderer service only: ISO timestamp of last successful Claude/Kimi usage render. */
|
|
let rendererUsageFetchedAt: string | null = null;
|
|
|
|
export interface WatcherTaskSummary {
|
|
active: number;
|
|
paused: number;
|
|
}
|
|
|
|
export function summarizeWatcherTasks(
|
|
tasks: Array<Pick<ScheduledTask, 'prompt' | 'status'>>,
|
|
): WatcherTaskSummary {
|
|
let active = 0;
|
|
let paused = 0;
|
|
|
|
for (const task of tasks) {
|
|
if (!isWatchCiTask(task)) continue;
|
|
if (task.status === 'active') active += 1;
|
|
if (task.status === 'paused') paused += 1;
|
|
}
|
|
|
|
return { active, paused };
|
|
}
|
|
|
|
export function formatStatusHeader(args: {
|
|
totalActive: number;
|
|
totalRooms: number;
|
|
watchers: WatcherTaskSummary;
|
|
}): string {
|
|
const parts = [
|
|
`**📊 에이전트 상태** — 활성 ${args.totalActive} / ${args.totalRooms}`,
|
|
`감시 ${args.watchers.active}`,
|
|
];
|
|
|
|
if (args.watchers.paused > 0) {
|
|
parts.push(`일시정지 ${args.watchers.paused}`);
|
|
}
|
|
|
|
return parts.join(' | ');
|
|
}
|
|
|
|
function findDiscordChannel(channels: Channel[]): Channel | undefined {
|
|
return channels.find(
|
|
(channel) => channel.name.startsWith('discord') && channel.isConnected(),
|
|
);
|
|
}
|
|
|
|
export 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);
|
|
}
|
|
}
|
|
|
|
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.roomBindings()).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.roomBindings()[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',
|
|
serviceId: string,
|
|
): string {
|
|
if (agentType === 'claude-code') return '클코';
|
|
return serviceId === 'codex-review' ? '코리뷰' : '코덱스';
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export function buildWebUsageRowsForSnapshot(args: {
|
|
serviceAgentType: AgentType;
|
|
claudeAccounts: ClaudeAccountUsage[];
|
|
kimiUsage: KimiUsageData | null;
|
|
codexRows: UsageRow[];
|
|
}): UsageRow[] {
|
|
const rows: UsageRow[] = [];
|
|
|
|
if (args.serviceAgentType === 'claude-code') {
|
|
rows.push(...buildClaudeUsageRows(args.claudeAccounts));
|
|
rows.push(...buildKimiUsageRows(args.kimiUsage));
|
|
}
|
|
|
|
rows.push(...args.codexRows);
|
|
return rows;
|
|
}
|
|
|
|
function buildUsageSnapshotRows(opts: UnifiedDashboardOptions): {
|
|
rows: UsageRow[];
|
|
fetchedAt: string | null;
|
|
} {
|
|
const rows = buildWebUsageRowsForSnapshot({
|
|
serviceAgentType: opts.serviceAgentType,
|
|
claudeAccounts: cachedClaudeAccounts,
|
|
kimiUsage: cachedKimiUsage,
|
|
codexRows: cachedCodexUsageRows,
|
|
});
|
|
|
|
const fetchedAt =
|
|
[rendererUsageFetchedAt, codexUsageFetchedAt]
|
|
.filter((value): value is string => !!value)
|
|
.sort()
|
|
.at(-1) ?? null;
|
|
|
|
return { rows, fetchedAt };
|
|
}
|
|
|
|
function writeLocalStatusSnapshot(opts: UnifiedDashboardOptions): void {
|
|
const groups = opts.roomBindings();
|
|
const statuses = opts.queue.getStatuses(Object.keys(groups));
|
|
const usageSnapshot = buildUsageSnapshotRows(opts);
|
|
|
|
writeStatusSnapshot({
|
|
serviceId: opts.serviceId,
|
|
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;
|
|
}>,
|
|
...(usageSnapshot.rows.length > 0 && { usageRows: usageSnapshot.rows }),
|
|
...(usageSnapshot.fetchedAt && {
|
|
usageRowsFetchedAt: usageSnapshot.fetchedAt,
|
|
}),
|
|
});
|
|
}
|
|
|
|
function buildStatusContent(): string {
|
|
if (!STATUS_SHOW_ROOMS) return '';
|
|
|
|
const snapshots = readStatusSnapshots(STATUS_SNAPSHOT_MAX_AGE_MS);
|
|
const watcherSummary = summarizeWatcherTasks(getAllTasks());
|
|
const chatNameByJid = new Map(
|
|
getAllChats().map((chat) => [chat.jid, chat.name]),
|
|
);
|
|
|
|
interface RoomEntry {
|
|
serviceId: string;
|
|
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({
|
|
serviceId: snapshot.serviceId,
|
|
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, agent.serviceId);
|
|
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 = formatStatusHeader({
|
|
totalActive,
|
|
totalRooms,
|
|
watchers: watcherSummary,
|
|
});
|
|
if (!STATUS_SHOW_ROOM_DETAILS) {
|
|
return header;
|
|
}
|
|
|
|
const sections = renderCategorizedRoomSections({
|
|
lines: roomLines,
|
|
showCategoryHeaders: channelMetaCache.size > 0,
|
|
});
|
|
return `${header}\n\n${sections}`;
|
|
}
|
|
|
|
/**
|
|
* Render usage table lines from two row groups (Claude and Codex).
|
|
* Returns rendered lines including code block markers.
|
|
* Ordering: Claude rows → separator → Codex rows.
|
|
* Exported for testing.
|
|
*/
|
|
/**
|
|
* A row is considered "exhausted" (1% or less remaining in any window) when
|
|
* either the 5h or 7d utilization is at or above 99%. Rows where a window's
|
|
* percentage is unknown (h5pct/d7pct < 0) aren't counted toward exhaustion
|
|
* for that window.
|
|
*/
|
|
function isExhaustedRow(row: UsageRow): boolean {
|
|
const h5Out = row.h5pct >= 99;
|
|
const d7Out = row.d7pct >= 99;
|
|
return h5Out || d7Out;
|
|
}
|
|
|
|
function allRowsExhausted(rows: UsageRow[]): boolean {
|
|
if (rows.length === 0) return false;
|
|
return rows.every(isExhaustedRow);
|
|
}
|
|
|
|
export function renderUsageTable(
|
|
claudeBotRows: UsageRow[],
|
|
codexBotRows: UsageRow[],
|
|
): string[] {
|
|
// Per-source status: 조회 불가 (no rows) vs 사용량 없음 (all exhausted)
|
|
const claudeStatus: 'ok' | 'unavailable' | 'exhausted' =
|
|
claudeBotRows.length === 0
|
|
? 'unavailable'
|
|
: allRowsExhausted(claudeBotRows)
|
|
? 'exhausted'
|
|
: 'ok';
|
|
const codexStatus: 'ok' | 'unavailable' | 'exhausted' =
|
|
codexBotRows.length === 0
|
|
? 'unavailable'
|
|
: allRowsExhausted(codexBotRows)
|
|
? 'exhausted'
|
|
: 'ok';
|
|
|
|
const renderableClaude = claudeStatus === 'ok' ? claudeBotRows : [];
|
|
const renderableCodex = codexStatus === 'ok' ? codexBotRows : [];
|
|
const allRenderable = [...renderableClaude, ...renderableCodex];
|
|
|
|
const bar = (pct: number) => {
|
|
const filled = Math.max(0, Math.min(5, Math.round(pct / 20)));
|
|
return '█'.repeat(filled) + '░'.repeat(5 - filled);
|
|
};
|
|
|
|
const visualWidth = (s: string) =>
|
|
[...s].reduce((w, c) => w + (c.codePointAt(0)! > 0x7f ? 2 : 1), 0);
|
|
const maxNameWidth =
|
|
Math.max(8, ...allRenderable.map((r) => visualWidth(r.name))) + 1;
|
|
const padName = (s: string) =>
|
|
s + ' '.repeat(Math.max(0, maxNameWidth - visualWidth(s)));
|
|
const compactReset = (s: string) =>
|
|
s ? s.replace(/\s+/g, '').replace(/m$/, '') : '';
|
|
|
|
const statusLabel = (status: 'unavailable' | 'exhausted'): string =>
|
|
status === 'unavailable' ? '조회 불가' : '사용량 없음';
|
|
|
|
const lines: string[] = [];
|
|
|
|
const renderRows = (rows: UsageRow[]) => {
|
|
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(`${padName(row.name)}${h5} ${d7}`);
|
|
const r5 = compactReset(row.h5reset);
|
|
const r7 = compactReset(row.d7reset);
|
|
if (r5 || r7) {
|
|
const d7ColStart = maxNameWidth + 10;
|
|
let resetLine = ' '.repeat(maxNameWidth);
|
|
if (r5) resetLine += r5;
|
|
resetLine = resetLine.padEnd(d7ColStart);
|
|
if (r7) resetLine += r7;
|
|
lines.push(resetLine);
|
|
}
|
|
}
|
|
};
|
|
|
|
lines.push('```');
|
|
lines.push(`${' '.repeat(maxNameWidth)}5h 7d`);
|
|
|
|
// Claude section
|
|
if (claudeStatus === 'ok') {
|
|
renderRows(claudeBotRows);
|
|
} else {
|
|
lines.push(`${padName('Claude')}${statusLabel(claudeStatus)}`);
|
|
}
|
|
|
|
const separatorWidth = maxNameWidth + 20;
|
|
lines.push('─'.repeat(separatorWidth));
|
|
|
|
// Codex section
|
|
if (codexStatus === 'ok') {
|
|
renderRows(codexBotRows);
|
|
} else {
|
|
lines.push(`${padName('Codex')}${statusLabel(codexStatus)}`);
|
|
}
|
|
|
|
lines.push('```');
|
|
|
|
return lines;
|
|
}
|
|
|
|
async function buildUsageContent(): Promise<string> {
|
|
const shouldFetchClaudeUsage = USAGE_DASHBOARD_ENABLED;
|
|
let liveClaudeAccounts: ClaudeAccountUsage[] | null = null;
|
|
|
|
if (shouldFetchClaudeUsage) {
|
|
try {
|
|
liveClaudeAccounts = await fetchAllClaudeUsage();
|
|
} catch (err) {
|
|
logger.warn({ err }, 'Failed to fetch Claude usage for dashboard');
|
|
}
|
|
}
|
|
|
|
// Kimi usage
|
|
try {
|
|
cachedKimiUsage = await fetchKimiUsage();
|
|
} catch (err) {
|
|
logger.warn({ err }, 'Failed to fetch Kimi usage for dashboard');
|
|
}
|
|
|
|
const lines: string[] = ['📊 *사용량*'];
|
|
const bar = (pct: number) => {
|
|
const filled = Math.max(0, Math.min(5, Math.round(pct / 20)));
|
|
return '█'.repeat(filled) + '░'.repeat(5 - filled);
|
|
};
|
|
|
|
// Group 1: Claude bot
|
|
const claudeBotRows: UsageRow[] = [];
|
|
|
|
if (shouldFetchClaudeUsage) {
|
|
cachedClaudeAccounts = mergeClaudeDashboardAccounts(
|
|
liveClaudeAccounts,
|
|
cachedClaudeAccounts,
|
|
);
|
|
claudeBotRows.push(...buildClaudeUsageRows(cachedClaudeAccounts));
|
|
}
|
|
|
|
// Group 2: Codex bot — use in-process cache (unified service)
|
|
// or fall back to snapshot from separate Codex service.
|
|
const codexBotRows: UsageRow[] = [];
|
|
if (cachedCodexUsageRows.length > 0) {
|
|
codexBotRows.push(...cachedCodexUsageRows);
|
|
} else {
|
|
const codexSnapshot = readStatusSnapshots(STATUS_SNAPSHOT_MAX_AGE_MS).find(
|
|
(s) => s.serviceId === 'codex-main' || s.serviceId === 'codex',
|
|
);
|
|
codexBotRows.push(
|
|
...extractCodexUsageRows(codexSnapshot, USAGE_SNAPSHOT_MAX_AGE_MS),
|
|
);
|
|
}
|
|
|
|
// Group 3: Kimi coding plan
|
|
const kimiRows = buildKimiUsageRows(cachedKimiUsage);
|
|
claudeBotRows.push(...kimiRows);
|
|
|
|
lines.push(...renderUsageTable(claudeBotRows, codexBotRows));
|
|
|
|
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();
|
|
// os.freemem() includes buffers/cache as "used" — misleading.
|
|
// Read MemAvailable from /proc/meminfo for actual available memory.
|
|
let availableMem = os.freemem();
|
|
try {
|
|
const meminfo = fs.readFileSync('/proc/meminfo', 'utf-8');
|
|
const match = meminfo.match(/MemAvailable:\s+(\d+)\s+kB/);
|
|
if (match) availableMem = parseInt(match[1], 10) * 1024;
|
|
} catch {
|
|
/* non-Linux or unreadable — fall back to os.freemem() */
|
|
}
|
|
const usedMem = totalMem - availableMem;
|
|
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 buildModelConfigSection(): string {
|
|
const roleConfigs = [
|
|
{
|
|
label: 'Owner',
|
|
agentType: OWNER_AGENT_TYPE,
|
|
model: OWNER_MODEL_CONFIG.model,
|
|
},
|
|
{
|
|
label: 'Reviewer',
|
|
agentType: REVIEWER_AGENT_TYPE,
|
|
model: REVIEWER_MODEL_CONFIG.model,
|
|
},
|
|
{
|
|
label: 'Arbiter',
|
|
agentType: ARBITER_AGENT_TYPE,
|
|
model: ARBITER_MODEL_CONFIG.model,
|
|
},
|
|
];
|
|
|
|
const failover = getGlobalFailoverInfo();
|
|
const lines = ['🤖 *모델 구성*'];
|
|
for (const role of roleConfigs) {
|
|
if (!role.agentType && role.label === 'Arbiter') continue;
|
|
const type = role.agentType || '—';
|
|
const defaultModel =
|
|
type === 'codex' ? DEFAULT_CODEX_MODEL : DEFAULT_CLAUDE_MODEL;
|
|
const model = role.model || defaultModel;
|
|
|
|
// Show fallback status for claude-code roles when global failover is active
|
|
const isFallback = failover.active && type === 'claude-code';
|
|
if (isFallback) {
|
|
const fallbackModel = DEFAULT_CODEX_MODEL;
|
|
lines.push(` **${role.label}** — codex \`${fallbackModel}\` (fallback)`);
|
|
} else {
|
|
lines.push(` **${role.label}** — ${type} \`${model}\``);
|
|
}
|
|
}
|
|
|
|
// MoA status
|
|
const moaConfig = getMoaConfig();
|
|
if (moaConfig.enabled) {
|
|
const refs = moaConfig.referenceModels
|
|
.map((m) => `${m.name} \`${m.model}\``)
|
|
.join(', ');
|
|
lines.push(` **MoA** — ${refs}`);
|
|
}
|
|
|
|
if (failover.active) {
|
|
lines.push(` ⚠️ **Failover 활성** — ${failover.reason || '알 수 없음'}`);
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function buildUnifiedDashboardContent(): string {
|
|
const sections: string[] = [];
|
|
sections.push(buildModelConfigSection());
|
|
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();
|
|
rendererUsageFetchedAt = new Date().toISOString();
|
|
} catch (err) {
|
|
logger.warn({ err }, 'Failed to build usage content');
|
|
} 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 fetchAllClaudeProfiles();
|
|
await refreshUsageCache();
|
|
}
|
|
|
|
const updateStatus = async () => {
|
|
writeLocalStatusSnapshot(opts);
|
|
if (!isRenderer) return;
|
|
|
|
const channel = findDiscordChannel(opts.channels);
|
|
if (!channel) {
|
|
logger.warn(
|
|
{
|
|
channelCount: opts.channels.length,
|
|
names: opts.channels.map((c) => c.name),
|
|
connected: opts.channels.map((c) => c.isConnected()),
|
|
},
|
|
'Dashboard: no connected Discord channel found',
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await refreshChannelMeta(opts);
|
|
const content = buildUnifiedDashboardContent();
|
|
if (!content) {
|
|
logger.warn(
|
|
{
|
|
cachedUsageLength: cachedUsageContent.length,
|
|
statusShowRooms: STATUS_SHOW_ROOMS,
|
|
},
|
|
'Dashboard content empty, skipping render',
|
|
);
|
|
statusMessageId = null;
|
|
return;
|
|
}
|
|
|
|
if (statusMessageId && channel.editMessage) {
|
|
await channel.editMessage(statusJid, statusMessageId, content, {
|
|
rawMarkdown: true,
|
|
});
|
|
} else if (channel.sendAndTrack) {
|
|
const id = await channel.sendAndTrack(statusJid, content, {
|
|
rawMarkdown: true,
|
|
});
|
|
if (id) statusMessageId = id;
|
|
}
|
|
if (!dashboardUpdateLogged) {
|
|
logger.info(
|
|
{ messageId: statusMessageId, contentLength: content.length },
|
|
'Dashboard updated successfully (first)',
|
|
);
|
|
dashboardUpdateLogged = true;
|
|
}
|
|
} catch (err) {
|
|
logger.warn({ err }, 'Dashboard update failed');
|
|
statusMessageId = null;
|
|
}
|
|
};
|
|
|
|
setInterval(updateStatus, opts.statusUpdateInterval);
|
|
await updateStatus();
|
|
|
|
if (isRenderer) {
|
|
const evaluatePairedMode = async () => {
|
|
try {
|
|
await evaluateAndApplyAutoPairedMode({
|
|
queue: opts.queue,
|
|
sendNotice: async (jid, text) => {
|
|
const ch = opts.channels.find(
|
|
(c) =>
|
|
c.name.startsWith('discord') &&
|
|
c.isConnected() &&
|
|
c.ownsJid(jid),
|
|
);
|
|
if (ch) await ch.sendMessage(jid, text);
|
|
},
|
|
});
|
|
} catch (err) {
|
|
logger.warn({ err }, 'auto-paired-mode evaluation failed');
|
|
}
|
|
};
|
|
setInterval(async () => {
|
|
await refreshUsageCache();
|
|
await evaluatePairedMode();
|
|
}, RENDERER_USAGE_REFRESH_MS);
|
|
await evaluatePairedMode();
|
|
}
|
|
|
|
// Codex usage collection — runs in unified service regardless of renderer role.
|
|
const applyCodexRefresh = (result: {
|
|
rows: UsageRow[];
|
|
fetchedAt: string | null;
|
|
}) => {
|
|
cachedCodexUsageRows = result.rows;
|
|
if (result.fetchedAt) codexUsageFetchedAt = result.fetchedAt;
|
|
};
|
|
const isWarmupRuntimeBusy = () => {
|
|
const groups = opts.roomBindings();
|
|
return opts.queue
|
|
.getStatuses(Object.keys(groups))
|
|
.some((status) => status.status === 'processing');
|
|
};
|
|
let codexWarmupInFlight = false;
|
|
const runCodexWarmup = async () => {
|
|
if (!CODEX_WARMUP_CONFIG.enabled || codexWarmupInFlight) return;
|
|
codexWarmupInFlight = true;
|
|
try {
|
|
const result = await runCodexWarmupCycle(CODEX_WARMUP_CONFIG, {
|
|
shouldSkip: isWarmupRuntimeBusy,
|
|
});
|
|
if (result.status === 'warmed') {
|
|
applyCodexRefresh(await refreshAllCodexAccountUsage());
|
|
}
|
|
} catch (err) {
|
|
logger.warn({ err }, 'Codex warm-up cycle failed unexpectedly');
|
|
} finally {
|
|
codexWarmupInFlight = false;
|
|
}
|
|
};
|
|
void refreshAllCodexAccountUsage()
|
|
.then((r) => {
|
|
applyCodexRefresh(r);
|
|
return refreshActiveCodexUsage().then(applyCodexRefresh);
|
|
})
|
|
.then(() => runCodexWarmup());
|
|
setInterval(
|
|
() => void refreshActiveCodexUsage().then(applyCodexRefresh),
|
|
opts.usageUpdateInterval,
|
|
);
|
|
setInterval(
|
|
() =>
|
|
void refreshAllCodexAccountUsage()
|
|
.then(applyCodexRefresh)
|
|
.then(() => runCodexWarmup()),
|
|
CODEX_FULL_SCAN_INTERVAL,
|
|
);
|
|
if (CODEX_WARMUP_CONFIG.enabled) {
|
|
setInterval(() => void runCodexWarmup(), CODEX_WARMUP_CONFIG.intervalMs);
|
|
}
|
|
|
|
logger.info(
|
|
{
|
|
channelId: opts.statusChannelId,
|
|
isRenderer,
|
|
agentType: opts.serviceAgentType,
|
|
},
|
|
isRenderer
|
|
? 'Unified dashboard started'
|
|
: 'Status snapshot updater started',
|
|
);
|
|
}
|