feat: add codex review failover and suppress output hardening

This commit is contained in:
Eyejoker
2026-03-28 05:40:38 +09:00
parent d1c693fb17
commit ba9f6871b6
49 changed files with 3884 additions and 1763 deletions

View File

@@ -47,6 +47,7 @@ import type {
export interface UnifiedDashboardOptions {
assistantName: string;
serviceId: string;
serviceAgentType: AgentType;
statusChannelId: string;
statusUpdateInterval: number;
@@ -189,8 +190,12 @@ async function refreshChannelMeta(
}
}
function getAgentDisplayName(agentType: 'claude-code' | 'codex'): string {
return agentType === 'codex' ? '코덱스' : '클코';
function getAgentDisplayName(
agentType: 'claude-code' | 'codex',
serviceId: string,
): string {
if (agentType === 'claude-code') return '클코';
return serviceId === 'codex-review' ? '코리뷰' : '코덱스';
}
function formatRoomName(
@@ -216,6 +221,7 @@ function writeLocalStatusSnapshot(opts: UnifiedDashboardOptions): void {
const statuses = opts.queue.getStatuses(Object.keys(groups));
writeStatusSnapshot({
serviceId: opts.serviceId,
agentType: opts.serviceAgentType,
assistantName: opts.assistantName,
updatedAt: new Date().toISOString(),
@@ -261,6 +267,7 @@ function buildStatusContent(): string {
);
interface RoomEntry {
serviceId: string;
agentType: 'claude-code' | 'codex';
status: 'processing' | 'waiting' | 'inactive';
elapsedMs: number | null;
@@ -277,6 +284,7 @@ function buildStatusContent(): string {
for (const entry of snapshot.entries) {
const existing = byJid.get(entry.jid) || [];
existing.push({
serviceId: snapshot.serviceId,
agentType,
status: entry.status,
elapsedMs: entry.elapsedMs,
@@ -345,7 +353,7 @@ function buildStatusContent(): string {
elapsedMs: agent.elapsedMs,
pendingTasks: agent.pendingTasks,
});
const tag = getAgentDisplayName(agent.agentType);
const tag = getAgentDisplayName(agent.agentType, agent.serviceId);
return `${tag} ${icon} ${label}`;
});
roomLines.push({
@@ -373,6 +381,76 @@ function buildStatusContent(): string {
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.
*/
export function renderUsageTable(
claudeBotRows: UsageRow[],
codexBotRows: UsageRow[],
): string[] {
const allRows = [...claudeBotRows, ...codexBotRows];
if (allRows.length === 0) return ['_조회 불가_'];
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, ...allRows.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 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`);
renderRows(claudeBotRows);
if (claudeBotRows.length > 0 && codexBotRows.length > 0) {
const separatorWidth = maxNameWidth + 20;
lines.push('─'.repeat(separatorWidth));
}
renderRows(codexBotRows);
lines.push('```');
return lines;
}
async function buildUsageContent(): Promise<string> {
const shouldFetchClaudeUsage = USAGE_DASHBOARD_ENABLED;
let liveClaudeAccounts: ClaudeAccountUsage[] | null = null;
@@ -391,64 +469,27 @@ async function buildUsageContent(): Promise<string> {
return '█'.repeat(filled) + '░'.repeat(5 - filled);
};
const rows: UsageRow[] = [];
// Group 1: Claude bot
const claudeBotRows: UsageRow[] = [];
if (shouldFetchClaudeUsage) {
cachedClaudeAccounts = mergeClaudeDashboardAccounts(
liveClaudeAccounts,
cachedClaudeAccounts,
);
rows.push(...buildClaudeUsageRows(cachedClaudeAccounts));
claudeBotRows.push(...buildClaudeUsageRows(cachedClaudeAccounts));
}
// Codex usage: read from Codex service's own status snapshot.
// Each service owns its usage data — no cross-service auth access needed.
// Group 2: Codex bot (separate service)
const codexBotRows: UsageRow[] = [];
const codexSnapshot = readStatusSnapshots(STATUS_SNAPSHOT_MAX_AGE_MS).find(
(s) => s.agentType === 'codex',
(s) => s.serviceId === 'codex-main' || s.serviceId === 'codex',
);
codexBotRows.push(
...extractCodexUsageRows(codexSnapshot, USAGE_SNAPSHOT_MAX_AGE_MS),
);
rows.push(...extractCodexUsageRows(codexSnapshot, USAGE_SNAPSHOT_MAX_AGE_MS));
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(Math.max(0, maxNameWidth - visualWidth(s)));
// Strip whitespace and trailing 'm' from reset strings for compact display
const compactReset = (s: string) =>
s ? s.replace(/\s+/g, '').replace(/m$/, '') : '';
lines.push('```');
lines.push(`${' '.repeat(maxNameWidth)}5h 7d`);
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) {
// Align reset values under 5h / 7d columns
// h5 column starts at maxNameWidth; d7 column starts at maxNameWidth + 9 + 1
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('```');
} else {
lines.push('_조회 불가_');
}
lines.push(...renderUsageTable(claudeBotRows, codexBotRows));
lines.push('');
lines.push('🖥️ *서버*');