Files
EJClaw/src/unified-dashboard.ts
Eyejoker 3467e245d5 feat: auto memory pipeline — host-driven Memento recall/reflect
Add automatic memory integration so EJClaw host directly calls Memento
MCP for recall at session start and reflect on compact, removing
reliance on agent voluntary tool use.

Stage 1: New sessions get room memory briefing injected into system
prompt (CLAUDE.md / AGENTS.md) via host-side MCP client.

Stage 2: PreCompact hook automatically calls reflect + remember to
persist session summaries as room-memory fragments.

Also restores missing source files (token-rotation, codex-token-rotation,
claude-usage exports) to fix full build from source.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 03:48:36 +09:00

830 lines
25 KiB
TypeScript

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 {
fetchAllClaudeUsage,
fetchAllClaudeProfiles,
getClaudeProfile,
type ClaudeAccountUsage,
} from './claude-usage.js';
import {
getAllCodexAccounts,
updateCodexAccountUsage,
} from './codex-token-rotation.js';
import {
composeDashboardContent,
formatElapsed,
getStatusLabel as formatDashboardStatusLabel,
type DashboardRoomLine,
renderCategorizedRoomSections,
} from './dashboard-render.js';
import { getAllChats, updateRegisteredGroupName } from './db.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 cachedClaudeAccounts: ClaudeAccountUsage[] = [];
let usageUpdateInProgress = 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 formatResetRemaining(value: string | number): string {
try {
const date =
typeof value === 'number' ? new Date(value * 1000) : new Date(value);
const diffMs = date.getTime() - Date.now();
if (diffMs <= 0) return ' reset';
const hours = Math.floor(diffMs / 3_600_000);
const minutes = Math.floor((diffMs % 3_600_000) / 60_000);
if (hours >= 24) {
const days = Math.floor(hours / 24);
const remH = hours % 24;
return `${String(days).padStart(2)}d ${String(remH).padStart(2)}h`;
}
return `${String(hours).padStart(2)}h ${String(minutes).padStart(2)}m`;
} catch {
return String(value).padStart(6);
}
}
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.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 fetchCodexUsage(
codexHomeOverride?: string,
): 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);
const spawnEnv: Record<string, string> = {
...(process.env as Record<string, string>),
PATH: [
path.dirname(process.execPath),
path.join(os.homedir(), '.npm-global', 'bin'),
process.env.PATH || '',
].join(':'),
};
if (codexHomeOverride) {
spawnEnv.CODEX_HOME = codexHomeOverride;
}
try {
proc = spawn(codexBin, ['app-server'], {
stdio: ['pipe', 'pipe', 'pipe'],
env: spawnEnv,
});
} 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',
);
});
}
type UsageRow = {
name: string;
h5pct: number;
h5reset: string;
d7pct: number;
d7reset: string;
};
export function mergeClaudeDashboardAccounts(
liveAccounts: ClaudeAccountUsage[] | null | undefined,
cachedAccounts: ClaudeAccountUsage[],
): ClaudeAccountUsage[] {
if (!liveAccounts) return cachedAccounts;
const cachedByIndex = new Map(
cachedAccounts.map((account) => [account.index, account]),
);
return liveAccounts.map((account) => ({
...account,
usage: account.usage || cachedByIndex.get(account.index)?.usage || null,
}));
}
export function buildClaudeUsageRows(
claudeAccounts: ClaudeAccountUsage[],
): UsageRow[] {
const isMultiAccount = claudeAccounts.length > 1;
return claudeAccounts.map((account) => {
const usage = account.usage;
const h5 = usage?.five_hour;
const d7 = usage?.seven_day;
const profile = getClaudeProfile(account.index);
const planSuffix = profile ? ` ${profile.planType}` : '';
const label = isMultiAccount
? `Claude${account.index + 1}${account.isActive ? '*' : ''}${account.isRateLimited ? '!' : ''}${planSuffix}`
: `Claude${account.isActive ? '*' : ''}${account.isRateLimited ? '!' : ''}${planSuffix}`;
return {
name: label,
h5pct: h5
? h5.utilization > 1
? Math.round(h5.utilization)
: Math.round(h5.utilization * 100)
: -1,
h5reset: h5 ? formatResetRemaining(h5.resets_at) : '',
d7pct: d7
? d7.utilization > 1
? Math.round(d7.utilization)
: Math.round(d7.utilization * 100)
: -1,
d7reset: d7 ? formatResetRemaining(d7.resets_at) : '',
};
});
}
async function buildUsageContent(): Promise<string> {
const shouldFetchClaudeUsage = USAGE_DASHBOARD_ENABLED;
let liveClaudeAccounts: ClaudeAccountUsage[] | null = null;
const codexUsagePromise = fetchCodexUsage();
if (shouldFetchClaudeUsage) {
try {
liveClaudeAccounts = await fetchAllClaudeUsage();
} catch (err) {
logger.warn({ err }, 'Failed to fetch Claude usage for dashboard');
}
}
const codexUsage = await codexUsagePromise;
const lines: string[] = ['📊 *사용량*'];
const bar = (pct: number) => {
const filled = Math.round(pct / 10);
return '█'.repeat(filled) + '░'.repeat(10 - filled);
};
const rows: UsageRow[] = [];
if (shouldFetchClaudeUsage) {
cachedClaudeAccounts = mergeClaudeDashboardAccounts(
liveClaudeAccounts,
cachedClaudeAccounts,
);
rows.push(...buildClaudeUsageRows(cachedClaudeAccounts));
}
const codexAccounts = getAllCodexAccounts();
if (codexAccounts.length > 1) {
// Multi-account: show each account with plan + status
for (const acct of codexAccounts) {
const icon = acct.isActive ? '*' : acct.isRateLimited ? '!' : ' ';
const label = `Codex${acct.index + 1}${icon}`;
if (acct.isActive && 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: `${label} ${acct.planType}`,
h5pct: Math.round(limit.primary.usedPercent),
h5reset: formatResetRemaining(limit.primary.resetsAt),
d7pct: Math.round(limit.secondary.usedPercent),
d7reset: formatResetRemaining(limit.secondary.resetsAt),
});
}
} else {
// Show cached usage from last scan
const pct = acct.cachedUsagePct != null ? acct.cachedUsagePct : -1;
const d7pct =
acct.cachedUsageD7Pct != null ? acct.cachedUsageD7Pct : -1;
const reset = acct.resetAt || '';
const d7reset = acct.resetD7At || '';
rows.push({
name: `${label} ${acct.planType}`,
h5pct: pct,
h5reset: reset,
d7pct,
d7reset,
});
}
}
} else if (codexUsage && Array.isArray(codexUsage)) {
// Single account: existing behavior
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) {
// Emoji characters take 2 columns in monospace — count visual width
const visualWidth = (s: string) =>
[...s].reduce((w, c) => w + (c.codePointAt(0)! > 0x7f ? 2 : 1), 0);
const maxNameWidth =
Math.max(8, ...rows.map((r) => visualWidth(r.name))) + 1;
const padName = (s: string) =>
s + ' '.repeat(maxNameWidth - visualWidth(s));
lines.push('```');
lines.push(`${' '.repeat(maxNameWidth)}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)}%`
: ' — ';
const reset =
row.h5reset || row.d7reset
? ` ${row.h5reset || ''}${row.d7reset ? ` / ${row.d7reset}` : ''}`
: '';
lines.push(`${padName(row.name)}${h5} ${d7}${reset}`);
}
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');
}
function buildUnifiedDashboardContent(): string {
const sections: string[] = [];
if (STATUS_SHOW_ROOMS) {
sections.push(buildStatusContent());
}
if (cachedUsageContent) {
sections.push(cachedUsageContent);
}
return composeDashboardContent(sections);
}
const CODEX_ACCOUNTS_DIR = path.join(os.homedir(), '.codex-accounts');
const CODEX_FULL_SCAN_INTERVAL = 3_600_000; // 1 hour
/**
* Scan ALL Codex accounts by spawning app-server with each auth.
* Called on startup and every hour to keep cached usage fresh.
*/
async function refreshAllCodexAccountUsage(): Promise<void> {
const codexAccounts = getAllCodexAccounts();
if (codexAccounts.length <= 1) return;
logger.info(
{ accountCount: codexAccounts.length },
'Scanning all Codex accounts for usage data',
);
for (const acct of codexAccounts) {
const accountDir = path.join(CODEX_ACCOUNTS_DIR, String(acct.index + 1));
if (!fs.existsSync(accountDir)) continue;
try {
const usage = await fetchCodexUsage(accountDir);
if (usage && Array.isArray(usage)) {
const relevant = usage.filter(
(l) => l.primary.usedPercent > 0 || l.secondary.usedPercent > 0,
);
const display = relevant.length > 0 ? relevant : usage.slice(0, 1);
if (usage.length > 0) {
// Find max primary (5h) and secondary (7d) across all limits
// Always capture reset times from first limit as baseline
let maxH5 = 0;
let maxD7 = 0;
let h5Reset: string | number | undefined = usage[0].primary.resetsAt;
let d7Reset: string | number | undefined =
usage[0].secondary.resetsAt;
for (const limit of usage) {
if (limit.primary.usedPercent >= maxH5) {
maxH5 = limit.primary.usedPercent;
h5Reset = limit.primary.resetsAt;
}
if (limit.secondary.usedPercent >= maxD7) {
maxD7 = limit.secondary.usedPercent;
d7Reset = limit.secondary.resetsAt;
}
}
const pct = Math.round(maxH5);
const d7Pct = Math.round(maxD7);
const resetStr = h5Reset ? formatResetRemaining(h5Reset) : undefined;
const resetD7Str = d7Reset
? formatResetRemaining(d7Reset)
: undefined;
updateCodexAccountUsage(pct, resetStr, acct.index, d7Pct, resetD7Str);
logger.info(
{ account: acct.index + 1, h5: pct, d7: d7Pct, reset: resetStr },
`Codex account #${acct.index + 1} usage: 5h=${pct}% 7d=${d7Pct}%`,
);
}
}
} catch (err) {
logger.debug(
{ err, account: acct.index + 1 },
'Failed to fetch usage for Codex account',
);
}
}
}
async function refreshUsageCache(): Promise<void> {
if (usageUpdateInProgress) return;
usageUpdateInProgress = true;
try {
cachedUsageContent = await buildUsageContent();
} 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) 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);
} else if (channel.sendAndTrack) {
const id = await channel.sendAndTrack(statusJid, content);
if (id) statusMessageId = id;
}
} catch (err) {
logger.warn({ err }, 'Dashboard update failed');
statusMessageId = null;
}
};
setInterval(updateStatus, opts.statusUpdateInterval);
await updateStatus();
if (isRenderer) {
setInterval(refreshUsageCache, opts.usageUpdateInterval);
// Full scan of all Codex accounts on startup + hourly
// After scan, refresh dashboard so cached data is visible immediately
void refreshAllCodexAccountUsage().then(() => {
void refreshUsageCache().then(() => void updateStatus());
});
setInterval(
() =>
void refreshAllCodexAccountUsage().then(() => void refreshUsageCache()),
CODEX_FULL_SCAN_INTERVAL,
);
}
logger.info(
{
channelId: opts.statusChannelId,
isRenderer,
agentType: opts.serviceAgentType,
},
isRenderer
? 'Unified dashboard started'
: 'Status snapshot updater started',
);
}