refactor: extract dashboard and task helpers
This commit is contained in:
766
src/index.ts
766
src/index.ts
@@ -1,6 +1,4 @@
|
||||
import { ChildProcess, execSync, spawn } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
@@ -11,11 +9,9 @@ import {
|
||||
SERVICE_AGENT_TYPE,
|
||||
isSessionCommandSenderAllowed,
|
||||
STATUS_CHANNEL_ID,
|
||||
STATUS_SHOW_ROOMS,
|
||||
STATUS_UPDATE_INTERVAL,
|
||||
TIMEZONE,
|
||||
TRIGGER_PATTERN,
|
||||
USAGE_DASHBOARD_ENABLED,
|
||||
USAGE_UPDATE_INTERVAL,
|
||||
} from './config.js';
|
||||
import './channels/index.js';
|
||||
@@ -26,7 +22,6 @@ import {
|
||||
import { writeGroupsSnapshot } from './agent-runner.js';
|
||||
import { listAvailableGroups } from './available-groups.js';
|
||||
import {
|
||||
getAllChats,
|
||||
getAllRegisteredGroups,
|
||||
getAllSessions,
|
||||
getAllTasks,
|
||||
@@ -37,20 +32,13 @@ import {
|
||||
initDatabase,
|
||||
setRegisteredGroup,
|
||||
setRouterState,
|
||||
updateRegisteredGroupName,
|
||||
deleteSession,
|
||||
setSession,
|
||||
storeChatMetadata,
|
||||
storeMessage,
|
||||
} from './db.js';
|
||||
import {
|
||||
composeDashboardContent,
|
||||
formatElapsed,
|
||||
getStatusLabel as formatDashboardStatusLabel,
|
||||
renderCategorizedRoomSections,
|
||||
} from './dashboard-render.js';
|
||||
import { composeDashboardContent } from './dashboard-render.js';
|
||||
import { GroupQueue } from './group-queue.js';
|
||||
import { readEnvFile } from './env.js';
|
||||
import { resolveGroupFolderPath } from './group-folder.js';
|
||||
import { startIpcWatcher } from './ipc.js';
|
||||
import { findChannel, formatOutbound } from './router.js';
|
||||
@@ -70,12 +58,9 @@ import {
|
||||
shouldDropMessage,
|
||||
} from './sender-allowlist.js';
|
||||
import { createMessageRuntime } from './message-runtime.js';
|
||||
import {
|
||||
readStatusSnapshots,
|
||||
writeStatusSnapshot,
|
||||
} from './status-dashboard.js';
|
||||
import { startSchedulerLoop } from './task-scheduler.js';
|
||||
import { Channel, ChannelMeta, NewMessage, RegisteredGroup } from './types.js';
|
||||
import { startUnifiedDashboard } from './unified-dashboard.js';
|
||||
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
||||
import { logger } from './logger.js';
|
||||
import { normalizeStoredSeqCursor } from './message-cursor.js';
|
||||
|
||||
@@ -240,722 +225,6 @@ export function _setRegisteredGroups(
|
||||
registeredGroups = groups;
|
||||
}
|
||||
|
||||
// ── Status & Usage Dashboards ───────────────────────────────────
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)}GB`;
|
||||
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(0)}MB`;
|
||||
return `${(bytes / 1024).toFixed(0)}KB`;
|
||||
}
|
||||
|
||||
function usageEmoji(pct: number): string {
|
||||
if (pct >= 80) return '🔴';
|
||||
if (pct >= 50) return '🟡';
|
||||
return '🟢';
|
||||
}
|
||||
|
||||
function formatResetKST(value: string | number): string {
|
||||
try {
|
||||
// Handle unix timestamp (seconds) or ISO string
|
||||
const date =
|
||||
typeof value === 'number' ? new Date(value * 1000) : new Date(value);
|
||||
return date.toLocaleString('ko-KR', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_ICONS: Record<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);
|
||||
|
||||
Reference in New Issue
Block a user