From eeba0334f909b79f4fc433fd731acfb9938a2b22 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 27 Apr 2026 20:36:18 +0900 Subject: [PATCH 1/8] refactor dashboard room activity fetching Refactor selected-room dashboard activity fetching, split Rooms i18n, and tighten mobile usage layout. --- apps/dashboard/src/App.tsx | 139 ++++------------- apps/dashboard/src/i18n.ts | 207 +------------------------- apps/dashboard/src/i18n/rooms.ts | 205 +++++++++++++++++++++++++ apps/dashboard/src/styles.css | 100 ++++++++++--- apps/dashboard/src/useRoomActivity.ts | 68 +++++++++ 5 files changed, 390 insertions(+), 329 deletions(-) create mode 100644 apps/dashboard/src/i18n/rooms.ts create mode 100644 apps/dashboard/src/useRoomActivity.ts diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index ba961af..3a71d89 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -34,7 +34,6 @@ import { fetchAccounts, fetchDashboardData, fetchModelConfig, - fetchRoomsTimelineBatch, runInboxAction, runServiceAction, runScheduledTaskAction, @@ -52,6 +51,10 @@ import { type Locale, type Messages, } from './i18n'; +import { + useSelectedRoomActivity, + type RoomActivityMap, +} from './useRoomActivity'; import './styles.css'; interface DashboardState { @@ -60,8 +63,6 @@ interface DashboardState { tasks: DashboardTask[]; } -type RoomActivityMap = Record; - type UsageRow = DashboardOverview['usage']['rows'][number]; type InboxItem = DashboardOverview['inbox'][number]; type RiskLevel = 'ok' | 'warn' | 'critical'; @@ -2568,7 +2569,9 @@ function RoomBoardV2({ roomActivity, roomActivityLoading, roomMessageKey, + selectedJid, locale, + onSelectedJidChange, snapshots, t, }: { @@ -2585,13 +2588,14 @@ function RoomBoardV2({ roomActivity: RoomActivityMap; roomActivityLoading: boolean; roomMessageKey: string | null; + selectedJid: string | null; locale: Locale; + onSelectedJidChange: (jid: string | null) => void; snapshots: StatusSnapshot[]; t: Messages; }) { const [filter, setFilter] = useState('all'); const [sort, setSort] = useState('recent'); - const [selectedJid, setSelectedJid] = useState(null); const [drafts, setDrafts] = useState>({}); const allEntries: RoomEntryWithService[] = snapshots.flatMap((snapshot) => @@ -2601,10 +2605,6 @@ function RoomBoardV2({ })), ); - if (allEntries.length === 0) { - return {t.rooms.empty}; - } - const counts = { all: allEntries.length, processing: allEntries.filter((e) => e.status === 'processing').length, @@ -2632,6 +2632,15 @@ function RoomBoardV2({ const selectedEntry = sorted.find((e) => e.jid === selectedJid) ?? sorted[0] ?? null; + useEffect(() => { + const nextJid = selectedEntry?.jid ?? null; + if (nextJid !== selectedJid) onSelectedJidChange(nextJid); + }, [onSelectedJidChange, selectedEntry?.jid, selectedJid]); + + if (allEntries.length === 0) { + return {t.rooms.empty}; + } + function setDraft(jid: string, value: string) { setDrafts((previous) => ({ ...previous, [jid]: value })); } @@ -2705,7 +2714,7 @@ function RoomBoardV2({ aria-current={active ? 'page' : undefined} className={`rooms-list-item status-${entry.status}${active ? ' is-active' : ''}`} key={`${entry.serviceId}:${entry.jid}`} - onClick={() => setSelectedJid(entry.jid)} + onClick={() => onSelectedJidChange(entry.jid)} type="button" > @@ -3788,8 +3797,15 @@ function App() { const [serviceActionKey, setServiceActionKey] = useState(null); const [roomMessageKey, setRoomMessageKey] = useState(null); - const [roomActivity, setRoomActivity] = useState({}); - const [roomActivityLoading, setRoomActivityLoading] = useState(false); + const [selectedRoomJid, setSelectedRoomJid] = useState(null); + const { + refreshRoom: refreshRoomActivity, + roomActivity, + roomActivityLoading, + } = useSelectedRoomActivity({ + active: activeView === 'rooms', + selectedRoomJid: selectedRoomJid, + }); const [pendingMessages, setPendingMessages] = useState< Record> >({}); @@ -3956,8 +3972,7 @@ function App() { try { await sendRoomMessage(roomJid, text, requestId, nickname || null); try { - const fresh = await fetchRoomsTimelineBatch(); - setRoomActivity(fresh); + await refreshRoomActivity(roomJid); } catch { /* refresh will retry on next poll */ } @@ -4125,102 +4140,6 @@ function App() { return () => window.clearInterval(id); }, []); - const roomsJidsKey = useMemo(() => { - if (!data) return ''; - return [ - ...new Set( - data.snapshots.flatMap((snapshot) => - snapshot.entries.map((entry) => entry.jid), - ), - ), - ] - .sort() - .join('|'); - }, [data]); - - const roomsLastUpdatedKey = useMemo(() => { - if (!data) return ''; - return data.snapshots - .map((s) => s.updatedAt) - .sort() - .join('|'); - }, [data]); - - useEffect(() => { - if (activeView !== 'rooms' || !data) return; - if (!roomsJidsKey) { - setRoomActivity({}); - return; - } - - let cancelled = false; - let inFlight = false; - let pollIntervalId: number | null = null; - let es: EventSource | null = null; - - const applyMap = (map: RoomActivityMap) => { - if (!cancelled) setRoomActivity(map); - }; - - const fetchOnce = async (initial: boolean) => { - if (cancelled || inFlight) return; - inFlight = true; - if (initial) setRoomActivityLoading(true); - try { - applyMap(await fetchRoomsTimelineBatch()); - } catch { - /* keep last good state */ - } finally { - inFlight = false; - if (!cancelled && initial) setRoomActivityLoading(false); - } - }; - - const startPollingFallback = () => { - if (pollIntervalId !== null) return; - pollIntervalId = window.setInterval(() => void fetchOnce(false), 2000); - }; - - const stopPollingFallback = () => { - if (pollIntervalId === null) return; - window.clearInterval(pollIntervalId); - pollIntervalId = null; - }; - - if (typeof window !== 'undefined' && 'EventSource' in window) { - void fetchOnce(true); - try { - es = new EventSource('/api/stream'); - es.addEventListener('rooms-timeline', (event) => { - try { - const parsed = JSON.parse( - (event as MessageEvent).data, - ) as RoomActivityMap; - applyMap(parsed); - stopPollingFallback(); - } catch { - /* malformed event, ignore */ - } - }); - es.onerror = () => { - // Browser auto-reconnects; meanwhile keep data fresh via polling. - startPollingFallback(); - }; - } catch { - startPollingFallback(); - } - } else { - void fetchOnce(true); - startPollingFallback(); - } - - return () => { - cancelled = true; - stopPollingFallback(); - es?.close(); - }; - }, [activeView, roomsJidsKey]); - useEffect(() => { setPendingMessages((prev) => { const next: typeof prev = {}; @@ -4385,6 +4304,8 @@ function App() { roomActivityLoading={roomActivityLoading} roomMessageKey={roomMessageKey} locale={locale} + onSelectedJidChange={setSelectedRoomJid} + selectedJid={selectedRoomJid} snapshots={data.snapshots} t={t} /> diff --git a/apps/dashboard/src/i18n.ts b/apps/dashboard/src/i18n.ts index 97fe4de..5279c90 100644 --- a/apps/dashboard/src/i18n.ts +++ b/apps/dashboard/src/i18n.ts @@ -1,3 +1,5 @@ +import { roomMessages, type RoomMessages } from './i18n/rooms'; + export const LOCALES = ['ko', 'en', 'zh', 'ja'] as const; export type Locale = (typeof LOCALES)[number]; @@ -152,46 +154,7 @@ export interface Messages { confirmDecline: string; }; }; - rooms: { - empty: string; - cardsAria: string; - room: string; - service: string; - agent: string; - status: string; - queue: string; - queueWaitingMessages: string; - tasks: string; - elapsed: string; - activity: string; - loadingActivity: string; - noActivity: string; - task: string; - noTask: string; - currentTurn: string; - noTurn: string; - round: string; - attempt: string; - updated: string; - output: string; - latestOutput: string; - outputHistory: string; - noOutput: string; - recentMessages: string; - messageHistory: string; - noMessages: string; - details: string; - message: string; - messagePlaceholder: string; - send: string; - sending: string; - error: string; - filterAll: string; - sortRecent: string; - sortName: string; - sortQueue: string; - sortLabel: string; - }; + rooms: RoomMessages; usage: { empty: string; current: string; @@ -465,46 +428,7 @@ export const messages = { confirmDecline: 'Decline this item?', }, }, - rooms: { - empty: '룸 없음.', - cardsAria: '룸 상태 카드', - room: '룸', - service: '서비스', - agent: '에이전트', - status: '상태', - queue: '큐', - queueWaitingMessages: '대기 메시지', - tasks: '태스크', - elapsed: '경과', - activity: '진행', - loadingActivity: '진행 로딩 중', - noActivity: '진행 없음', - task: '태스크', - noTask: '태스크 없음', - currentTurn: '현재 턴', - noTurn: '턴 없음', - round: '라운드', - attempt: '시도', - updated: '갱신', - output: '출력', - latestOutput: '마지막 출력', - outputHistory: '출력 기록', - noOutput: '출력 없음', - recentMessages: '메시지 기록', - messageHistory: '메시지 기록', - noMessages: '메시지 없음', - details: '세부', - message: '메시지', - messagePlaceholder: '요청 입력...', - send: '전송', - sending: '전송 중...', - error: '오류', - filterAll: '전체', - sortRecent: '최근 활동', - sortName: '이름순', - sortQueue: '큐 많은 순', - sortLabel: '정렬', - }, + rooms: roomMessages.ko, usage: { empty: '사용량 스냅샷 없음. 수집기 확인.', current: '사용중', @@ -762,46 +686,7 @@ export const messages = { confirmDecline: 'Decline this item?', }, }, - rooms: { - empty: 'No rooms yet.', - cardsAria: 'Room status cards', - room: 'room', - service: 'service', - agent: 'agent', - status: 'status', - queue: 'queue', - queueWaitingMessages: 'pending messages', - tasks: 'tasks', - elapsed: 'elapsed', - activity: 'activity', - loadingActivity: 'Loading activity', - noActivity: 'No activity', - task: 'task', - noTask: 'No task', - currentTurn: 'Current turn', - noTurn: 'No turn', - round: 'round', - attempt: 'attempt', - updated: 'updated', - output: 'output', - latestOutput: 'latest output', - outputHistory: 'output log', - noOutput: 'No output', - recentMessages: 'Message log', - messageHistory: 'Message log', - noMessages: 'No messages', - details: 'details', - message: 'message', - messagePlaceholder: 'Type request...', - send: 'Send', - sending: 'Sending', - error: 'Error', - filterAll: 'All', - sortRecent: 'Recent activity', - sortName: 'Name', - sortQueue: 'Queue size', - sortLabel: 'Sort', - }, + rooms: roomMessages.en, usage: { empty: 'No usage snapshot. Check collector.', current: 'Active', @@ -1059,46 +944,7 @@ export const messages = { confirmDecline: '确认拒绝此项?', }, }, - rooms: { - empty: '暂无房间。', - cardsAria: '房间状态卡片', - room: '房间', - service: '服务', - agent: '代理', - status: '状态', - queue: '队列', - queueWaitingMessages: '待处理消息', - tasks: '任务', - elapsed: '耗时', - activity: '进展', - loadingActivity: '正在加载进展', - noActivity: '暂无进展', - task: '任务', - noTask: '无任务', - currentTurn: '当前回合', - noTurn: '无回合', - round: '轮次', - attempt: '尝试', - updated: '更新', - output: '输出', - latestOutput: '最新输出', - outputHistory: '输出记录', - noOutput: '暂无输出', - recentMessages: '消息记录', - messageHistory: '消息记录', - noMessages: '暂无消息', - details: '详情', - message: 'Message', - messagePlaceholder: '输入请求...', - send: '发送', - sending: '发送中...', - error: '错误', - filterAll: '全部', - sortRecent: '最近活动', - sortName: '名称', - sortQueue: '队列大小', - sortLabel: '排序', - }, + rooms: roomMessages.zh, usage: { empty: '暂无用量快照。检查采集器。', current: '使用中', @@ -1358,46 +1204,7 @@ export const messages = { confirmDecline: 'この項目を拒否しますか?', }, }, - rooms: { - empty: 'ルームなし。', - cardsAria: 'ルーム状態カード', - room: 'ルーム', - service: 'サービス', - agent: 'エージェント', - status: '状態', - queue: 'キュー', - queueWaitingMessages: '待機メッセージ', - tasks: 'タスク', - elapsed: '経過', - activity: '進行', - loadingActivity: '進行を読み込み中', - noActivity: '進行なし', - task: 'タスク', - noTask: 'タスクなし', - currentTurn: '現在ターン', - noTurn: 'ターンなし', - round: 'ラウンド', - attempt: '試行', - updated: '更新', - output: '出力', - latestOutput: '最新出力', - outputHistory: '出力履歴', - noOutput: '出力なし', - recentMessages: 'メッセージ履歴', - messageHistory: 'メッセージ履歴', - noMessages: 'メッセージなし', - details: '詳細', - message: 'メッセージ', - messagePlaceholder: '依頼を入力...', - send: '送信', - sending: '送信中...', - error: 'エラー', - filterAll: '全て', - sortRecent: '最近の活動', - sortName: '名前順', - sortQueue: 'キュー順', - sortLabel: '並び替え', - }, + rooms: roomMessages.ja, usage: { empty: '使用量スナップショットなし。収集器を確認。', current: '使用中', diff --git a/apps/dashboard/src/i18n/rooms.ts b/apps/dashboard/src/i18n/rooms.ts new file mode 100644 index 0000000..2ff27ed --- /dev/null +++ b/apps/dashboard/src/i18n/rooms.ts @@ -0,0 +1,205 @@ +import type { Locale } from '../i18n'; + +export interface RoomMessages { + empty: string; + cardsAria: string; + room: string; + service: string; + agent: string; + status: string; + queue: string; + queueWaitingMessages: string; + tasks: string; + elapsed: string; + activity: string; + loadingActivity: string; + noActivity: string; + task: string; + noTask: string; + currentTurn: string; + noTurn: string; + round: string; + attempt: string; + updated: string; + output: string; + latestOutput: string; + outputHistory: string; + noOutput: string; + recentMessages: string; + messageHistory: string; + noMessages: string; + details: string; + message: string; + messagePlaceholder: string; + send: string; + sending: string; + error: string; + filterAll: string; + sortRecent: string; + sortName: string; + sortQueue: string; + sortLabel: string; +} + +export const roomMessages = { + ko: { + empty: '룸 없음.', + cardsAria: '룸 상태 카드', + room: '룸', + service: '서비스', + agent: '에이전트', + status: '상태', + queue: '큐', + queueWaitingMessages: '대기 메시지', + tasks: '태스크', + elapsed: '경과', + activity: '진행', + loadingActivity: '진행 로딩 중', + noActivity: '진행 없음', + task: '태스크', + noTask: '태스크 없음', + currentTurn: '현재 턴', + noTurn: '턴 없음', + round: '라운드', + attempt: '시도', + updated: '갱신', + output: '출력', + latestOutput: '마지막 출력', + outputHistory: '출력 기록', + noOutput: '출력 없음', + recentMessages: '메시지 기록', + messageHistory: '메시지 기록', + noMessages: '메시지 없음', + details: '세부', + message: '메시지', + messagePlaceholder: '요청 입력...', + send: '전송', + sending: '전송 중...', + error: '오류', + filterAll: '전체', + sortRecent: '최근 활동', + sortName: '이름순', + sortQueue: '큐 많은 순', + sortLabel: '정렬', + }, + en: { + empty: 'No rooms yet.', + cardsAria: 'Room status cards', + room: 'room', + service: 'service', + agent: 'agent', + status: 'status', + queue: 'queue', + queueWaitingMessages: 'pending messages', + tasks: 'tasks', + elapsed: 'elapsed', + activity: 'activity', + loadingActivity: 'Loading activity', + noActivity: 'No activity', + task: 'task', + noTask: 'No task', + currentTurn: 'Current turn', + noTurn: 'No turn', + round: 'round', + attempt: 'attempt', + updated: 'updated', + output: 'output', + latestOutput: 'latest output', + outputHistory: 'output log', + noOutput: 'No output', + recentMessages: 'Message log', + messageHistory: 'Message log', + noMessages: 'No messages', + details: 'details', + message: 'message', + messagePlaceholder: 'Type request...', + send: 'Send', + sending: 'Sending', + error: 'Error', + filterAll: 'All', + sortRecent: 'Recent activity', + sortName: 'Name', + sortQueue: 'Queue size', + sortLabel: 'Sort', + }, + zh: { + empty: '暂无房间。', + cardsAria: '房间状态卡片', + room: '房间', + service: '服务', + agent: '代理', + status: '状态', + queue: '队列', + queueWaitingMessages: '待处理消息', + tasks: '任务', + elapsed: '耗时', + activity: '进展', + loadingActivity: '正在加载进展', + noActivity: '暂无进展', + task: '任务', + noTask: '无任务', + currentTurn: '当前回合', + noTurn: '无回合', + round: '轮次', + attempt: '尝试', + updated: '更新', + output: '输出', + latestOutput: '最新输出', + outputHistory: '输出记录', + noOutput: '暂无输出', + recentMessages: '消息记录', + messageHistory: '消息记录', + noMessages: '暂无消息', + details: '详情', + message: 'Message', + messagePlaceholder: '输入请求...', + send: '发送', + sending: '发送中...', + error: '错误', + filterAll: '全部', + sortRecent: '最近活动', + sortName: '名称', + sortQueue: '队列大小', + sortLabel: '排序', + }, + ja: { + empty: 'ルームなし。', + cardsAria: 'ルーム状態カード', + room: 'ルーム', + service: 'サービス', + agent: 'エージェント', + status: '状態', + queue: 'キュー', + queueWaitingMessages: '待機メッセージ', + tasks: 'タスク', + elapsed: '経過', + activity: '進行', + loadingActivity: '進行を読み込み中', + noActivity: '進行なし', + task: 'タスク', + noTask: 'タスクなし', + currentTurn: '現在ターン', + noTurn: 'ターンなし', + round: 'ラウンド', + attempt: '試行', + updated: '更新', + output: '出力', + latestOutput: '最新出力', + outputHistory: '出力履歴', + noOutput: '出力なし', + recentMessages: 'メッセージ履歴', + messageHistory: 'メッセージ履歴', + noMessages: 'メッセージなし', + details: '詳細', + message: 'メッセージ', + messagePlaceholder: '依頼を入力...', + send: '送信', + sending: '送信中...', + error: 'エラー', + filterAll: '全て', + sortRecent: '最近の活動', + sortName: '名前順', + sortQueue: 'キュー順', + sortLabel: '並び替え', + }, +} satisfies Record; diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index 0c41bc2..9f726b3 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -3004,35 +3004,66 @@ progress::-moz-progress-bar { .usage-row { grid-template-columns: - minmax(86px, 0.86fr) minmax(62px, 0.54fr) minmax(62px, 0.54fr) - minmax(58px, 0.4fr); - gap: 5px; - padding: 9px; + minmax(72px, 0.82fr) minmax(52px, 0.48fr) minmax(52px, 0.48fr) + minmax(48px, 0.36fr); + gap: 4px; + padding: 7px 6px; } .usage-account { display: grid; - gap: 5px; + gap: 3px; + } + + .usage-account div { + gap: 3px; + } + + .usage-account strong { + font-size: 13px; + } + + .usage-account .pill, + .usage-account .mono-chip { + padding: 3px 6px; + font-size: 10px; } .usage-quota, .usage-speed { - gap: 4px; + gap: 3px; } .usage-quota { - padding: 5px; + padding: 4px; } .usage-quota div { - gap: 6px; + gap: 4px; + } + + .usage-quota span, + .usage-speed span { + font-size: 9px; + letter-spacing: 0.04em; + } + + .usage-quota strong, + .usage-speed strong { + font-size: 14px; + } + + .usage-speed { + min-height: 0; + padding: 4px; } .usage-quota small { - overflow: hidden; - font-size: 10px; - text-overflow: ellipsis; - white-space: nowrap; + display: none; + } + + .usage-speed small { + display: none; } .record-card-grid { @@ -3168,7 +3199,7 @@ progress::-moz-progress-bar { } .usage-summary { - grid-template-columns: 1fr; + grid-template-columns: minmax(0, 1fr) auto; } .usage-summary div:last-child { @@ -3176,19 +3207,19 @@ progress::-moz-progress-bar { } .usage-row { - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + grid-template-columns: + minmax(68px, 0.8fr) minmax(48px, 0.48fr) minmax(48px, 0.48fr) + minmax(44px, 0.34fr); } .usage-account { - grid-column: 1 / -1; + grid-column: auto; } .usage-speed { - grid-column: 1 / -1; - display: flex; - min-height: auto; - align-items: center; - justify-content: space-between; + grid-column: auto; + display: grid; + min-height: 0; } .skeleton-grid, @@ -4582,6 +4613,35 @@ progress::-moz-progress-bar { color: var(--muted); } +@media (max-width: 640px) { + .usage-matrix { + overflow-x: hidden; + } + + .usage-row { + grid-template-columns: minmax(0, 1fr) 62px 62px !important; + align-items: center; + } + + .usage-speed { + display: none; + } + + .usage-account strong { + font-size: 13px; + } + + .usage-account .pill, + .usage-account .mono-chip { + padding: 3px 6px; + font-size: 10px; + } + + .usage-quota { + min-width: 0; + } +} + @media (max-width: 760px) { .rooms-grid { grid-template-columns: 1fr; diff --git a/apps/dashboard/src/useRoomActivity.ts b/apps/dashboard/src/useRoomActivity.ts new file mode 100644 index 0000000..800df46 --- /dev/null +++ b/apps/dashboard/src/useRoomActivity.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { fetchRoomTimeline, type DashboardRoomActivity } from './api'; + +export type RoomActivityMap = Record; + +interface UseSelectedRoomActivityOptions { + active: boolean; + pollMs?: number; + selectedRoomJid: string | null; +} + +export function useSelectedRoomActivity({ + active, + pollMs = 2000, + selectedRoomJid, +}: UseSelectedRoomActivityOptions): { + refreshRoom: (roomJid: string) => Promise; + roomActivity: RoomActivityMap; + roomActivityLoading: boolean; +} { + const [roomActivity, setRoomActivity] = useState({}); + const [roomActivityLoading, setRoomActivityLoading] = useState(false); + + const refreshRoom = useCallback(async (roomJid: string) => { + const activity = await fetchRoomTimeline(roomJid); + setRoomActivity((previous) => ({ + ...previous, + [roomJid]: activity, + })); + return activity; + }, []); + + useEffect(() => { + if (!active || !selectedRoomJid) { + setRoomActivityLoading(false); + return undefined; + } + + let cancelled = false; + let inFlight = false; + let pollIntervalId: number | null = null; + + const fetchOnce = async (initial: boolean) => { + if (cancelled || inFlight) return; + inFlight = true; + if (initial) setRoomActivityLoading(true); + try { + await refreshRoom(selectedRoomJid); + } catch { + /* Keep the last good timeline snapshot. */ + } finally { + inFlight = false; + if (!cancelled && initial) setRoomActivityLoading(false); + } + }; + + void fetchOnce(true); + pollIntervalId = window.setInterval(() => void fetchOnce(false), pollMs); + + return () => { + cancelled = true; + if (pollIntervalId !== null) window.clearInterval(pollIntervalId); + }; + }, [active, pollMs, refreshRoom, selectedRoomJid]); + + return { refreshRoom, roomActivity, roomActivityLoading }; +} From 5883e57d734223c795a89b33e9570e26a14f4be5 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 27 Apr 2026 21:58:01 +0900 Subject: [PATCH 2/8] Settings: show codex account email + subscription expiry (#45) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract email and chatgpt_subscription_last_checked from codex JWT in addition to plan_type and subscription_active_until. - Settings → 계정 row now displays: email · plan badge · expiry badge with active/soon/expired color coding (green/amber/red). - Fix codex/.codex-accounts directory scan picking up sibling dirs like "1.bak-..." and double-counting an index (require dirname to be fully numeric). Motivation: rotation was landing on accounts whose Pro/Team subscription had silently expired, causing OpenAI to reject upper-tier models with a misleading "model not supported" error. Surface expiry in the UI so the user can see at a glance which accounts need renewal. Co-authored-by: Claude Opus 4.7 --- apps/dashboard/src/App.tsx | 152 ++++++++++++++++++++++++---------- apps/dashboard/src/api.ts | 2 + apps/dashboard/src/styles.css | 59 ++++++++++++- src/settings-store.ts | 12 +++ 4 files changed, 176 insertions(+), 49 deletions(-) diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 3a71d89..162485c 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -1234,6 +1234,34 @@ function ModelSettings({ onRestartStack }: { onRestartStack: () => void }) { ); } +function formatExpiry( + iso: string | null, +): { label: string; cls: string } | null { + if (!iso) return null; + const dt = new Date(iso); + if (Number.isNaN(dt.getTime())) return null; + const days = (dt.getTime() - Date.now()) / 86400000; + const dateStr = dt.toLocaleDateString('ko-KR', { + year: '2-digit', + month: '2-digit', + day: '2-digit', + }); + if (days < 0) { + const ago = Math.ceil(-days); + return { label: `${dateStr} 만료 (${ago}일 전)`, cls: 'is-expired' }; + } + if (days < 7) { + return { + label: `${dateStr}까지 (${Math.floor(days)}일)`, + cls: 'is-soon', + }; + } + return { + label: `${dateStr}까지 (${Math.floor(days)}일)`, + cls: 'is-active', + }; +} + function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) { const [data, setData] = useState<{ claude: ClaudeAccountSummary[]; @@ -1309,29 +1337,39 @@ function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {

계정 없음

) : (
    - {data.claude.map((acc) => ( -
  • - #{acc.index} - - {acc.subscriptionType ?? 'unknown'} - {acc.expiresAt - ? ` · 만료 ${new Date(acc.expiresAt).toLocaleDateString()}` - : ''} - - {acc.index > 0 ? ( - - ) : ( - 기본 - )} -
  • - ))} + {data.claude.map((acc) => { + const expiry = formatExpiry( + acc.expiresAt ? new Date(acc.expiresAt).toISOString() : null, + ); + return ( +
  • +
    + #{acc.index} + + {acc.subscriptionType ?? 'unknown'} + {acc.rateLimitTier ? ` · ${acc.rateLimitTier}` : ''} + + {expiry ? ( + + {expiry.label} + + ) : null} +
    + {acc.index > 0 ? ( + + ) : ( + 기본 + )} +
  • + ); + })}
)}
@@ -1359,29 +1397,51 @@ function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {

계정 없음

) : (
    - {data.codex.map((acc) => ( -
  • - #{acc.index} - - {acc.planType ?? 'unknown'} - {acc.subscriptionUntil - ? ` · ${acc.subscriptionUntil}까지` - : ''} - - {acc.index > 0 ? ( - - ) : ( - 기본 - )} -
  • - ))} + {data.codex.map((acc) => { + const expiry = formatExpiry(acc.subscriptionUntil); + return ( +
  • +
    + #{acc.index} + {acc.email ? ( + + {acc.email} + + ) : null} + + {acc.planType ?? 'unknown'} + + {expiry ? ( + + {expiry.label} + + ) : null} +
    + {acc.index > 0 ? ( + + ) : ( + 기본 + )} +
  • + ); + })}
)}

diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts index 01cf643..a62416d 100644 --- a/apps/dashboard/src/api.ts +++ b/apps/dashboard/src/api.ts @@ -355,8 +355,10 @@ export interface ClaudeAccountSummary { export interface CodexAccountSummary { index: number; accountId: string | null; + email: string | null; planType: string | null; subscriptionUntil: string | null; + subscriptionLastChecked: string | null; exists: boolean; } diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index 9f726b3..4824dd1 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -864,21 +864,74 @@ button:disabled { .settings-account-row { display: grid; - grid-template-columns: 40px minmax(0, 1fr) auto; + grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: center; - padding: 6px 8px; + padding: 8px 10px; border: 1px solid var(--panel-border); - border-radius: 6px; + border-radius: 8px; background: rgba(255, 255, 255, 0.02); } +.settings-account-main { + display: flex; + flex-wrap: wrap; + gap: 6px 10px; + align-items: center; + min-width: 0; +} + .settings-account-tag { + flex: none; font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 11px; color: var(--muted); } +.settings-account-email { + font-size: 13px; + font-weight: 600; + color: var(--bg-ink); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} + +.settings-account-plan { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 2px 7px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + color: var(--muted); +} + +.settings-account-badge { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 999px; + font-variant-numeric: tabular-nums; +} + +.settings-account-badge.is-active { + background: rgba(80, 180, 130, 0.14); + color: #6dc89a; +} + +.settings-account-badge.is-soon { + background: rgba(214, 170, 60, 0.16); + color: #e0bc5b; +} + +.settings-account-badge.is-expired { + background: rgba(224, 100, 75, 0.16); + color: var(--status-critical, #e0644b); +} + .settings-account-meta { font-size: 12px; color: var(--bg-ink); diff --git a/src/settings-store.ts b/src/settings-store.ts index 95826e3..11954fb 100644 --- a/src/settings-store.ts +++ b/src/settings-store.ts @@ -27,8 +27,10 @@ export interface ClaudeAccountSummary { export interface CodexAccountSummary { index: number; accountId: string | null; + email: string | null; planType: string | null; subscriptionUntil: string | null; + subscriptionLastChecked: string | null; exists: boolean; } @@ -105,8 +107,10 @@ function readCodexAccount(index: number): CodexAccountSummary | null { tokens?: { id_token?: string; access_token?: string }; }>(file); let accountId: string | null = null; + let email: string | null = null; let planType: string | null = null; let subscriptionUntil: string | null = null; + let subscriptionLastChecked: string | null = null; const idToken = data?.tokens?.id_token; if (idToken && typeof idToken === 'string') { const parts = idToken.split('.'); @@ -116,6 +120,7 @@ function readCodexAccount(index: number): CodexAccountSummary | null { Buffer.from(parts[1], 'base64').toString('utf-8'), ) as Record; if (typeof payload.sub === 'string') accountId = payload.sub; + if (typeof payload.email === 'string') email = payload.email; const auth = payload['https://api.openai.com/auth'] as | Record | undefined; @@ -126,6 +131,9 @@ function readCodexAccount(index: number): CodexAccountSummary | null { if (typeof auth.chatgpt_subscription_active_until === 'string') { subscriptionUntil = auth.chatgpt_subscription_active_until; } + if (typeof auth.chatgpt_subscription_last_checked === 'string') { + subscriptionLastChecked = auth.chatgpt_subscription_last_checked; + } } } catch { /* ignore parse errors */ @@ -135,8 +143,10 @@ function readCodexAccount(index: number): CodexAccountSummary | null { return { index, accountId, + email, planType, subscriptionUntil, + subscriptionLastChecked, exists: true, }; } @@ -149,6 +159,7 @@ export function listClaudeAccounts(): ClaudeAccountSummary[] { if (fs.existsSync(dir)) { const entries = fs.readdirSync(dir); const indices = entries + .filter((e) => /^\d+$/.test(e)) .map((e) => Number.parseInt(e, 10)) .filter((n) => Number.isFinite(n) && n >= 1) .sort((a, b) => a - b); @@ -168,6 +179,7 @@ export function listCodexAccounts(): CodexAccountSummary[] { if (fs.existsSync(dir)) { const entries = fs.readdirSync(dir); const indices = entries + .filter((e) => /^\d+$/.test(e)) .map((e) => Number.parseInt(e, 10)) .filter((n) => Number.isFinite(n) && n >= 1) .sort((a, b) => a - b); From 7047caa608dfe19bbe63d3be72e112ed6090ba4c Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 27 Apr 2026 22:06:56 +0900 Subject: [PATCH 3/8] Settings: add Fast Mode toggle for codex/claude + tidy account row alignment (#46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fast mode controls - Codex: writes [features].fast_mode = true|false in ~/.codex/config.toml. This matches the in-TUI /fast toggle ("toggle Fast mode to enable fastest inference with increased plan usage"). - Claude: writes fastMode: bool to ~/.claude/settings.json. Mirrors the /fast slash command preference (effective on opus-4-6). UX polish - Account rows switch from flex-wrap to a fixed grid (tag · email · plan · expiry · action) so emails of varying lengths don't cause ragged baselines between rows. Co-authored-by: Claude Opus 4.7 --- apps/dashboard/src/App.tsx | 89 +++++++++++++++++++++++++++++++ apps/dashboard/src/api.ts | 33 ++++++++++++ apps/dashboard/src/styles.css | 44 ++++++++++++++-- src/settings-store.ts | 98 +++++++++++++++++++++++++++++++++++ src/web-dashboard-server.ts | 31 +++++++++++ 5 files changed, 290 insertions(+), 5 deletions(-) diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 162485c..9e7221b 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -24,6 +24,7 @@ import { type DashboardTaskAction, type DashboardOverview, type DashboardTask, + type FastModeSnapshot, type ModelConfigSnapshot, type ModelRoleConfig, type UpdateScheduledTaskInput, @@ -33,11 +34,13 @@ import { deleteAccount, fetchAccounts, fetchDashboardData, + fetchFastMode, fetchModelConfig, runInboxAction, runServiceAction, runScheduledTaskAction, sendRoomMessage, + updateFastMode, updateModels, updateScheduledTask, } from './api'; @@ -1098,6 +1101,8 @@ function SettingsPanel({ + +

); @@ -1234,6 +1239,90 @@ function ModelSettings({ onRestartStack }: { onRestartStack: () => void }) { ); } +function FastModeSettings() { + const [state, setState] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + fetchFastMode() + .then((s) => { + if (cancelled) return; + setState(s); + setError(null); + }) + .catch((err) => { + if (cancelled) return; + setError(err instanceof Error ? err.message : String(err)); + }); + return () => { + cancelled = true; + }; + }, []); + + async function toggle(provider: keyof FastModeSnapshot) { + if (!state) return; + const next = !state[provider]; + const optimistic = { ...state, [provider]: next }; + setState(optimistic); + setBusy(true); + setError(null); + try { + const fresh = await updateFastMode({ [provider]: next }); + setState(fresh); + } catch (err) { + setState(state); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + return ( +
+

패스트 모드

+ {error ?

{error}

: null} + {!state ? ( +

불러오는 중…

+ ) : ( + <> + + + + )} +
+ ); +} + function formatExpiry( iso: string | null, ): { label: string; cls: string } | null { diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts index a62416d..c804eca 100644 --- a/apps/dashboard/src/api.ts +++ b/apps/dashboard/src/api.ts @@ -373,6 +373,11 @@ export interface ModelConfigSnapshot { arbiter: ModelRoleConfig; } +export interface FastModeSnapshot { + codex: boolean; + claude: boolean; +} + export async function fetchAccounts(): Promise<{ claude: ClaudeAccountSummary[]; codex: CodexAccountSummary[]; @@ -412,6 +417,34 @@ export async function updateModels( return (await response.json()) as ModelConfigSnapshot; } +export async function fetchFastMode(): Promise { + return fetchJson('/api/settings/fast-mode'); +} + +export async function updateFastMode( + input: Partial, +): Promise { + const response = await fetch('/api/settings/fast-mode', { + method: 'PUT', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + body: JSON.stringify(input), + }); + if (!response.ok) { + let msg = `update fast mode failed: ${response.status}`; + try { + const payload = (await response.json()) as { error?: string }; + if (payload.error) msg = payload.error; + } catch { + /* ignore */ + } + throw new Error(msg); + } + return (await response.json()) as FastModeSnapshot; +} + export async function deleteAccount( provider: 'claude' | 'codex', index: number, diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index 4824dd1..b0cd849 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -814,6 +814,40 @@ button:disabled { margin-top: 8px; } +.settings-toggle-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 10px 12px; + border: 1px solid var(--panel-border); + border-radius: 8px; + background: rgba(255, 255, 255, 0.02); +} + +.settings-toggle-row + .settings-toggle-row { + margin-top: 6px; +} + +.settings-toggle-label { + display: grid; + gap: 4px; + min-width: 0; +} + +.settings-toggle-title { + font-size: 13px; + font-weight: 600; + color: var(--bg-ink); +} + +.settings-toggle-row input[type='checkbox'] { + width: 18px; + height: 18px; + cursor: pointer; + accent-color: var(--accent); +} + .settings-save, .settings-restart { min-height: 32px; @@ -874,18 +908,18 @@ button:disabled { } .settings-account-main { - display: flex; - flex-wrap: wrap; - gap: 6px 10px; + display: grid; + grid-template-columns: 28px minmax(0, 1fr) auto auto; + gap: 10px; align-items: center; min-width: 0; } .settings-account-tag { - flex: none; font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 11px; color: var(--muted); + text-align: right; } .settings-account-email { @@ -895,7 +929,7 @@ button:disabled { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - max-width: 100%; + min-width: 0; } .settings-account-plan { diff --git a/src/settings-store.ts b/src/settings-store.ts index 11954fb..7c0cec5 100644 --- a/src/settings-store.ts +++ b/src/settings-store.ts @@ -45,6 +45,11 @@ export interface ModelConfigSnapshot { arbiter: ModelRoleConfig; } +export interface FastModeSnapshot { + codex: boolean; + claude: boolean; +} + const ROLE_KEYS = ['OWNER', 'REVIEWER', 'ARBITER'] as const; type RoleKey = (typeof ROLE_KEYS)[number]; @@ -272,6 +277,99 @@ export function updateModelConfig( return getModelConfig(); } +function codexConfigPath(): string { + return path.join(os.homedir(), '.codex', 'config.toml'); +} + +function claudeSettingsPath(): string { + return path.join(os.homedir(), '.claude', 'settings.json'); +} + +function readCodexFastMode(): boolean { + const file = codexConfigPath(); + if (!fs.existsSync(file)) return false; + const content = fs.readFileSync(file, 'utf-8'); + // [features] section, look for fast_mode = true|false + const featuresMatch = content.match(/\[features\][\s\S]*?(?=^\[|\Z)/m); + const block = featuresMatch ? featuresMatch[0] : content; + const m = block.match(/^\s*fast_mode\s*=\s*(true|false)\s*$/m); + return m ? m[1] === 'true' : false; +} + +function writeCodexFastMode(value: boolean): void { + const file = codexConfigPath(); + let content = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : ''; + if (/^\s*fast_mode\s*=\s*(true|false)\s*$/m.test(content)) { + content = content.replace( + /^\s*fast_mode\s*=\s*(true|false)\s*$/m, + `fast_mode = ${value}`, + ); + } else if (/^\[features\]/m.test(content)) { + content = content.replace( + /^\[features\]\s*$/m, + `[features]\nfast_mode = ${value}`, + ); + } else { + const trimmed = content.replace(/\s*$/, ''); + content = `${trimmed}\n\n[features]\nfast_mode = ${value}\n`; + } + const tempPath = `${file}.tmp`; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(tempPath, content, { mode: 0o600 }); + fs.renameSync(tempPath, file); +} + +function readClaudeFastMode(): boolean { + const file = claudeSettingsPath(); + if (!fs.existsSync(file)) return false; + try { + const data = JSON.parse(fs.readFileSync(file, 'utf-8')) as Record< + string, + unknown + >; + return data.fastMode === true; + } catch { + return false; + } +} + +function writeClaudeFastMode(value: boolean): void { + const file = claudeSettingsPath(); + fs.mkdirSync(path.dirname(file), { recursive: true }); + let data: Record = {}; + if (fs.existsSync(file)) { + try { + data = JSON.parse(fs.readFileSync(file, 'utf-8')) as Record< + string, + unknown + >; + } catch { + data = {}; + } + } + data.fastMode = value; + const tempPath = `${file}.tmp`; + fs.writeFileSync(tempPath, `${JSON.stringify(data, null, 2)}\n`, { + mode: 0o600, + }); + fs.renameSync(tempPath, file); +} + +export function getFastMode(): FastModeSnapshot { + return { + codex: readCodexFastMode(), + claude: readClaudeFastMode(), + }; +} + +export function updateFastMode( + input: Partial, +): FastModeSnapshot { + if (typeof input.codex === 'boolean') writeCodexFastMode(input.codex); + if (typeof input.claude === 'boolean') writeClaudeFastMode(input.claude); + return getFastMode(); +} + export function removeAccountDirectory( provider: 'claude' | 'codex', index: number, diff --git a/src/web-dashboard-server.ts b/src/web-dashboard-server.ts index 34bf1e6..6d2c990 100644 --- a/src/web-dashboard-server.ts +++ b/src/web-dashboard-server.ts @@ -48,10 +48,12 @@ import { } from './web-dashboard-data.js'; import { addClaudeAccountFromToken, + getFastMode, getModelConfig, listClaudeAccounts, listCodexAccounts, removeAccountDirectory, + updateFastMode, updateModelConfig, } from './settings-store.js'; @@ -1271,6 +1273,31 @@ export function createWebDashboardHandler( } } + if ( + url.pathname === '/api/settings/fast-mode' && + (request.method === 'PUT' || request.method === 'PATCH') + ) { + let body: unknown = null; + try { + body = await request.json(); + } catch { + return jsonResponse({ error: 'Invalid JSON body' }, { status: 400 }); + } + if (!body || typeof body !== 'object') { + return jsonResponse( + { error: 'Body must be a JSON object' }, + { status: 400 }, + ); + } + try { + const next = updateFastMode(body as Record); + return jsonResponse(next); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return jsonResponse({ error: message }, { status: 500 }); + } + } + { const accountAddMatch = url.pathname.match( /^\/api\/settings\/accounts\/(claude)$/, @@ -1518,6 +1545,10 @@ export function createWebDashboardHandler( return jsonResponse(getModelConfig()); } + if (url.pathname === '/api/settings/fast-mode') { + return jsonResponse(getFastMode()); + } + if (url.pathname === '/api/stream') { const encoder = new TextEncoder(); const stream = new ReadableStream({ From 2f7e09e194cf489b1b6eb6e9032be09f9d39d8a2 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 27 Apr 2026 22:11:59 +0900 Subject: [PATCH 4/8] Settings: relax 480px panel cap and align account row right side (#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump .settings-panel max-width 480px → 760px so settings page actually uses available desktop width (the 480px cap was a leftover from an initial mobile-first sketch). - Account row main grid columns become fixed: 28px tag · 1fr email · 60px plan · 180px expiry. Plan and expiry badges now line up vertically across rows regardless of email length. Co-authored-by: Claude Opus 4.7 --- apps/dashboard/src/styles.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index b0cd849..1f4a650 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -722,7 +722,8 @@ button:disabled { .settings-panel { display: grid; gap: 14px; - max-width: 480px; + max-width: 760px; + width: 100%; } .settings-row { @@ -909,7 +910,7 @@ button:disabled { .settings-account-main { display: grid; - grid-template-columns: 28px minmax(0, 1fr) auto auto; + grid-template-columns: 28px minmax(0, 1fr) 60px 180px; gap: 10px; align-items: center; min-width: 0; From c2a6889567bead1704cfabd36da0dd281a2a651c Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 27 Apr 2026 22:23:06 +0900 Subject: [PATCH 5/8] Codex accounts: 6h auto-refresh, manual refresh, manual switch (#48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto refresh - New refreshCodexAccount(index) calls https://auth.openai.com/oauth/token with grant_type=refresh_token and persists rotated tokens back to ~/.codex-accounts/{N}/auth.json. JWT's subscription_active_until reflects the latest plan state OpenAI hands back. - startCodexAccountRefreshLoop() runs every 6h (first run delayed 60s after boot to keep startup snappy). Hooked into main()/shutdown() alongside the existing claude token refresh loop. Manual controls (Settings → 계정 → Codex) - "갱신" button per row: forces an immediate refresh for that account. - "전체 갱신" button: refreshes all codex accounts in sequence. - "전환" button: writes data/codex-rotation-state.json so the next codex spawn picks the chosen account. Active account marked with a green- bordered card and "사용중" badge. Endpoints - POST /api/settings/accounts/codex/{i}/refresh - POST /api/settings/accounts/codex/refresh-all - PUT /api/settings/accounts/codex/current { index } - GET /api/settings/accounts now also returns codexCurrentIndex. Why JWTs cache subscription state at issue-time. When a Pro plan lapses or a user upgrades/downgrades, the dashboard kept showing stale data until the user logged in again. Periodic refresh + explicit "갱신" button keeps the displayed state honest, and the manual switch unblocks the user when rotation lands on a known-bad account. Co-authored-by: Claude Opus 4.7 --- apps/dashboard/src/App.tsx | 115 ++++++++++++++++++++++++++---- apps/dashboard/src/api.ts | 59 +++++++++++++++ apps/dashboard/src/styles.css | 39 ++++++++++ src/codex-token-rotation.ts | 36 ++++++++++ src/index.ts | 6 ++ src/settings-store.ts | 130 ++++++++++++++++++++++++++++++++++ src/web-dashboard-server.ts | 65 +++++++++++++++++ 7 files changed, 437 insertions(+), 13 deletions(-) diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 9e7221b..5905aa3 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -36,10 +36,13 @@ import { fetchDashboardData, fetchFastMode, fetchModelConfig, + refreshAllCodexAccounts as refreshAllCodexAccountsApi, + refreshCodexAccount as refreshCodexAccountApi, runInboxAction, runServiceAction, runScheduledTaskAction, sendRoomMessage, + setCurrentCodexAccount as setCurrentCodexAccountApi, updateFastMode, updateModels, updateScheduledTask, @@ -1355,8 +1358,10 @@ function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) { const [data, setData] = useState<{ claude: ClaudeAccountSummary[]; codex: CodexAccountSummary[]; + codexCurrentIndex?: number; } | null>(null); const [busy, setBusy] = useState(false); + const [perRowBusy, setPerRowBusy] = useState(null); const [error, setError] = useState(null); const [tokenInput, setTokenInput] = useState(''); @@ -1413,6 +1418,50 @@ function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) { } } + async function handleCodexRefresh(index: number) { + setPerRowBusy(`refresh:${index}`); + setError(null); + try { + await refreshCodexAccountApi(index); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setPerRowBusy(null); + } + } + + async function handleRefreshAllCodex() { + setBusy(true); + setError(null); + try { + const result = await refreshAllCodexAccountsApi(); + await refresh(); + if (result.failed.length > 0) { + setError( + `일부 갱신 실패: ${result.failed.map((f) => `#${f.index}`).join(', ')}`, + ); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + async function handleSwitchCodex(index: number) { + setPerRowBusy(`switch:${index}`); + setError(null); + try { + await setCurrentCodexAccountApi(index); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setPerRowBusy(null); + } + } + return (

계정

@@ -1479,7 +1528,17 @@ function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {
-

Codex

+
+

Codex

+ +
{!data ? (

{busy ? '불러오는 중…' : '없음'}

) : data.codex.length === 0 ? ( @@ -1488,10 +1547,18 @@ function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {
    {data.codex.map((acc) => { const expiry = formatExpiry(acc.subscriptionUntil); + const isActive = data.codexCurrentIndex === acc.index; + const refreshing = perRowBusy === `refresh:${acc.index}`; + const switching = perRowBusy === `switch:${acc.index}`; return ( -
  • +
  • - #{acc.index} + + {isActive ? '●' : ''}#{acc.index} + {acc.email ? ( void }) { ) : null}
    - {acc.index > 0 ? ( +
    - ) : ( - 기본 - )} + {!isActive ? ( + + ) : ( + 사용중 + )} + {acc.index > 0 ? ( + + ) : null} +
  • ); })}
)}

- Codex 계정 추가는 codex login CLI로 진행한 뒤 - ~/.codex-accounts/N/ 디렉터리에 auth.json 파일이 생성되면 됩니다. + OAuth 토큰은 6시간마다 자동 갱신됩니다. plan 변경/해지가 즉시 반영되게 + 하려면 수동으로 “전체 갱신”을 누르세요.

diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts index c804eca..4fc84ae 100644 --- a/apps/dashboard/src/api.ts +++ b/apps/dashboard/src/api.ts @@ -381,6 +381,7 @@ export interface FastModeSnapshot { export async function fetchAccounts(): Promise<{ claude: ClaudeAccountSummary[]; codex: CodexAccountSummary[]; + codexCurrentIndex?: number; }> { return fetchJson('/api/settings/accounts'); } @@ -417,6 +418,64 @@ export async function updateModels( return (await response.json()) as ModelConfigSnapshot; } +export async function refreshCodexAccount( + index: number, +): Promise { + const response = await fetch( + `/api/settings/accounts/codex/${index}/refresh`, + { method: 'POST' }, + ); + if (!response.ok) { + let msg = `refresh failed: ${response.status}`; + try { + const payload = (await response.json()) as { error?: string }; + if (payload.error) msg = payload.error; + } catch { + /* ignore */ + } + throw new Error(msg); + } + const json = (await response.json()) as { account: CodexAccountSummary }; + return json.account; +} + +export async function refreshAllCodexAccounts(): Promise<{ + refreshed: number[]; + failed: Array<{ index: number; error: string }>; +}> { + const response = await fetch('/api/settings/accounts/codex/refresh-all', { + method: 'POST', + }); + if (!response.ok) { + throw new Error(`refresh-all failed: ${response.status}`); + } + return (await response.json()) as { + refreshed: number[]; + failed: Array<{ index: number; error: string }>; + }; +} + +export async function setCurrentCodexAccount( + index: number, +): Promise<{ codexCurrentIndex: number }> { + const response = await fetch('/api/settings/accounts/codex/current', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ index }), + }); + if (!response.ok) { + let msg = `switch failed: ${response.status}`; + try { + const payload = (await response.json()) as { error?: string }; + if (payload.error) msg = payload.error; + } catch { + /* ignore */ + } + throw new Error(msg); + } + return (await response.json()) as { codexCurrentIndex: number }; +} + export async function fetchFastMode(): Promise { return fetchJson('/api/settings/fast-mode'); } diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index 1f4a650..b59fb55 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -897,6 +897,13 @@ button:disabled { gap: 4px; } +.settings-account-group-head { + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; +} + .settings-account-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -908,6 +915,38 @@ button:disabled { background: rgba(255, 255, 255, 0.02); } +.settings-account-row.is-active-account { + border-color: rgba(80, 180, 130, 0.45); + background: rgba(80, 180, 130, 0.06); +} + +.settings-account-actions { + display: flex; + gap: 6px; + align-items: center; +} + +.settings-secondary { + min-height: 28px; + padding: 0 10px; + border: 1px solid var(--panel-border-strong); + border-radius: 6px; + background: rgba(255, 255, 255, 0.04); + color: var(--bg-ink); + font-size: 11px; + font-weight: 700; + cursor: pointer; +} + +.settings-secondary:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.settings-secondary:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.08); +} + .settings-account-main { display: grid; grid-template-columns: 28px minmax(0, 1fr) 60px 180px; diff --git a/src/codex-token-rotation.ts b/src/codex-token-rotation.ts index bb7b03c..b93bbe9 100644 --- a/src/codex-token-rotation.ts +++ b/src/codex-token-rotation.ts @@ -256,6 +256,42 @@ export function getActiveCodexAuthPath(): string | null { return getCodexAuthPath(); } +/** Currently active codex account index (after rotation). */ +export function getCurrentCodexAccountIndex(): number { + return accounts.length === 0 ? 0 : currentIndex; +} + +/** Manually switch the active codex account. Clears its rate-limit cooldown. */ +export function setCurrentCodexAccountIndex(targetIndex: number): void { + if (accounts.length === 0) { + throw new Error('codex token rotation: no accounts loaded'); + } + if ( + !Number.isInteger(targetIndex) || + targetIndex < 0 || + targetIndex >= accounts.length + ) { + throw new Error( + `codex switch: index ${targetIndex} out of range [0..${accounts.length - 1}]`, + ); + } + if (targetIndex === currentIndex) return; + const previous = currentIndex; + currentIndex = targetIndex; + // Clear rate-limit on the chosen account so rotation logic doesn't bounce it. + accounts[targetIndex].rateLimitedUntil = null; + saveCodexState(); + logger.info( + { + transition: 'rotation:manual-switch', + fromIndex: previous, + toIndex: currentIndex, + totalAccounts: accounts.length, + }, + `Codex switched manually to account #${currentIndex + 1}/${accounts.length}`, + ); +} + export function getCodexAuthPath( accountIndex: number = currentIndex, ): string | null { diff --git a/src/index.ts b/src/index.ts index 5326688..b749aba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,6 +59,10 @@ import { startWebDashboardServer } from './web-dashboard-server.js'; import { Channel, NewMessage, RegisteredGroup } from './types.js'; import { logger } from './logger.js'; import { initCodexTokenRotation } from './codex-token-rotation.js'; +import { + startCodexAccountRefreshLoop, + stopCodexAccountRefreshLoop, +} from './settings-store.js'; import { hasAvailableClaudeToken, initTokenRotation, @@ -245,6 +249,7 @@ async function main(): Promise { initTokenRotation(); initCodexTokenRotation(); startTokenRefreshLoop(); + startCodexAccountRefreshLoop(); runtimeState.loadState(); @@ -254,6 +259,7 @@ async function main(): Promise { const shutdown = async (signal: string) => { logger.info({ signal }, 'Shutdown signal received'); stopTokenRefreshLoop(); + stopCodexAccountRefreshLoop(); if (leaseRecoveryTimer) { clearInterval(leaseRecoveryTimer); leaseRecoveryTimer = null; diff --git a/src/settings-store.ts b/src/settings-store.ts index 7c0cec5..3519439 100644 --- a/src/settings-store.ts +++ b/src/settings-store.ts @@ -277,6 +277,136 @@ export function updateModelConfig( return getModelConfig(); } +const CODEX_OAUTH_TOKEN_URL = 'https://auth.openai.com/oauth/token'; +const CODEX_OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'; + +interface CodexAuthFile { + auth_mode?: string; + OPENAI_API_KEY?: string | null; + tokens?: { + id_token?: string; + access_token?: string; + refresh_token?: string; + account_id?: string; + }; + last_refresh?: string; +} + +function readCodexAuthFile(file: string): CodexAuthFile | null { + return readJson(file); +} + +function writeCodexAuthFile(file: string, data: CodexAuthFile): void { + const tempPath = `${file}.tmp`; + fs.writeFileSync(tempPath, `${JSON.stringify(data, null, 2)}\n`, { + mode: 0o600, + }); + fs.renameSync(tempPath, file); +} + +export async function refreshCodexAccount( + index: number, +): Promise { + const file = codexAuthPath(index); + if (!fs.existsSync(file)) { + throw new Error(`codex auth.json not found for index ${index}`); + } + const data = readCodexAuthFile(file); + const refreshToken = data?.tokens?.refresh_token; + if (!refreshToken) { + throw new Error(`codex account #${index} has no refresh_token`); + } + + const body = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: CODEX_OAUTH_CLIENT_ID, + }); + + const res = await fetch(CODEX_OAUTH_TOKEN_URL, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error( + `codex refresh failed (#${index}): ${res.status} ${text.slice(0, 200)}`, + ); + } + + const payload = (await res.json()) as { + access_token?: string; + id_token?: string; + refresh_token?: string; + }; + + const updated: CodexAuthFile = { + ...(data ?? {}), + tokens: { + ...(data?.tokens ?? {}), + id_token: payload.id_token ?? data?.tokens?.id_token, + access_token: payload.access_token ?? data?.tokens?.access_token, + refresh_token: payload.refresh_token ?? refreshToken, + }, + last_refresh: new Date().toISOString(), + }; + writeCodexAuthFile(file, updated); + + const summary = readCodexAccount(index); + if (!summary) + throw new Error('failed to re-read codex account after refresh'); + return summary; +} + +export async function refreshAllCodexAccounts(): Promise<{ + refreshed: number[]; + failed: Array<{ index: number; error: string }>; +}> { + const refreshed: number[] = []; + const failed: Array<{ index: number; error: string }> = []; + const accounts = listCodexAccounts(); + for (const acc of accounts) { + try { + await refreshCodexAccount(acc.index); + refreshed.push(acc.index); + } catch (err) { + failed.push({ + index: acc.index, + error: err instanceof Error ? err.message : String(err), + }); + } + } + return { refreshed, failed }; +} + +const CODEX_REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000; +let codexRefreshTimer: ReturnType | null = null; + +export function startCodexAccountRefreshLoop(): void { + if (codexRefreshTimer) return; + // Stagger first refresh so server boot isn't slowed. + setTimeout(() => { + void refreshAllCodexAccounts().catch(() => { + /* swallow; per-account errors logged inside */ + }); + }, 60_000).unref?.(); + codexRefreshTimer = setInterval(() => { + void refreshAllCodexAccounts().catch(() => { + /* swallow */ + }); + }, CODEX_REFRESH_INTERVAL_MS); + codexRefreshTimer.unref?.(); +} + +export function stopCodexAccountRefreshLoop(): void { + if (codexRefreshTimer) { + clearInterval(codexRefreshTimer); + codexRefreshTimer = null; + } +} + function codexConfigPath(): string { return path.join(os.homedir(), '.codex', 'config.toml'); } diff --git a/src/web-dashboard-server.ts b/src/web-dashboard-server.ts index 6d2c990..506d680 100644 --- a/src/web-dashboard-server.ts +++ b/src/web-dashboard-server.ts @@ -52,10 +52,16 @@ import { getModelConfig, listClaudeAccounts, listCodexAccounts, + refreshAllCodexAccounts, + refreshCodexAccount, removeAccountDirectory, updateFastMode, updateModelConfig, } from './settings-store.js'; +import { + getCurrentCodexAccountIndex, + setCurrentCodexAccountIndex, +} from './codex-token-rotation.js'; const DEFAULT_STATUS_MAX_AGE_MS = 10 * 60 * 1000; const ROOM_MESSAGE_ID_CACHE_LIMIT = 500; @@ -1340,6 +1346,64 @@ export function createWebDashboardHandler( } } + { + const refreshMatch = url.pathname.match( + /^\/api\/settings\/accounts\/codex\/(\d+)\/refresh$/, + ); + if (refreshMatch && request.method === 'POST') { + const index = Number.parseInt(refreshMatch[1], 10); + try { + const updated = await refreshCodexAccount(index); + return jsonResponse({ ok: true, account: updated }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return jsonResponse({ error: message }, { status: 400 }); + } + } + } + + if ( + url.pathname === '/api/settings/accounts/codex/refresh-all' && + request.method === 'POST' + ) { + try { + const result = await refreshAllCodexAccounts(); + return jsonResponse({ ok: true, ...result }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return jsonResponse({ error: message }, { status: 500 }); + } + } + + if ( + url.pathname === '/api/settings/accounts/codex/current' && + request.method === 'PUT' + ) { + let body: { index?: unknown } | null = null; + try { + body = (await request.json()) as { index?: unknown }; + } catch { + return jsonResponse({ error: 'Invalid JSON body' }, { status: 400 }); + } + const idx = typeof body?.index === 'number' ? body.index : Number.NaN; + if (!Number.isInteger(idx)) { + return jsonResponse( + { error: 'index must be an integer' }, + { status: 400 }, + ); + } + try { + setCurrentCodexAccountIndex(idx); + return jsonResponse({ + ok: true, + codexCurrentIndex: getCurrentCodexAccountIndex(), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return jsonResponse({ error: message }, { status: 400 }); + } + } + if (url.pathname === '/api/tasks' && request.method === 'POST') { if (!loadRoomBindings) { return jsonResponse( @@ -1538,6 +1602,7 @@ export function createWebDashboardHandler( return jsonResponse({ claude: listClaudeAccounts(), codex: listCodexAccounts(), + codexCurrentIndex: getCurrentCodexAccountIndex(), }); } From dafb84fa4df997c548863d6146d4cebcf48d41b7 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 27 Apr 2026 22:26:17 +0900 Subject: [PATCH 6/8] =?UTF-8?q?Settings:=20clarify=20Codex=20=EB=A7=8C?= =?UTF-8?q?=EB=A3=8C=3D=EA=B2=B0=EC=A0=9C,=20drop=20Claude=20expiry=20badg?= =?UTF-8?q?e=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Codex expiry badge now says "결제 만료/까지" so it's unambiguous that this is the ChatGPT Pro/Team subscription end-date, not an OAuth access-token TTL. - Claude rows no longer show an expiry badge: Claude credentials only expose an OAuth access_token TTL (auto-refreshed every few minutes by the existing claude token refresh loop), which is meaningless to the user. Replace it with a static "토큰 자동갱신" tag so the column alignment stays consistent. Co-authored-by: Claude Opus 4.7 --- apps/dashboard/src/App.tsx | 69 ++++++++++++++++++-------------------- 1 file changed, 33 insertions(+), 36 deletions(-) diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 5905aa3..5df8763 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -1340,16 +1340,19 @@ function formatExpiry( }); if (days < 0) { const ago = Math.ceil(-days); - return { label: `${dateStr} 만료 (${ago}일 전)`, cls: 'is-expired' }; + return { + label: `결제 만료 ${dateStr} (${ago}일 전)`, + cls: 'is-expired', + }; } if (days < 7) { return { - label: `${dateStr}까지 (${Math.floor(days)}일)`, + label: `결제 ${dateStr}까지 (${Math.floor(days)}일)`, cls: 'is-soon', }; } return { - label: `${dateStr}까지 (${Math.floor(days)}일)`, + label: `결제 ${dateStr}까지 (${Math.floor(days)}일)`, cls: 'is-active', }; } @@ -1475,39 +1478,33 @@ function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {

계정 없음

) : (
    - {data.claude.map((acc) => { - const expiry = formatExpiry( - acc.expiresAt ? new Date(acc.expiresAt).toISOString() : null, - ); - return ( -
  • -
    - #{acc.index} - - {acc.subscriptionType ?? 'unknown'} - {acc.rateLimitTier ? ` · ${acc.rateLimitTier}` : ''} - - {expiry ? ( - - {expiry.label} - - ) : null} -
    - {acc.index > 0 ? ( - - ) : ( - 기본 - )} -
  • - ); - })} + {data.claude.map((acc) => ( +
  • +
    + #{acc.index} + + {acc.subscriptionType ?? 'unknown'} + {acc.rateLimitTier ? ` · ${acc.rateLimitTier}` : ''} + + claude + + 토큰 자동갱신 + +
    + {acc.index > 0 ? ( + + ) : ( + 기본 + )} +
  • + ))}
)}
From 4ed12332d4cb438d47d0a454c3fa4c7dc5de3e03 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 27 Apr 2026 22:33:22 +0900 Subject: [PATCH 7/8] Codex refresh: pull live plan_type from /wham/usage (#50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Settings: clarify Codex 만료=결제, drop Claude expiry badge - Codex expiry badge now says "결제 만료/까지" so it's unambiguous that this is the ChatGPT Pro/Team subscription end-date, not an OAuth access-token TTL. - Claude rows no longer show an expiry badge: Claude credentials only expose an OAuth access_token TTL (auto-refreshed every few minutes by the existing claude token refresh loop), which is meaningless to the user. Replace it with a static "토큰 자동갱신" tag so the column alignment stays consistent. Co-Authored-By: Claude Opus 4.7 * Codex refresh: pull live plan_type from /wham/usage (sidecar override) OAuth refresh_token grant returns a fresh JWT but auth0 caches the chatgpt_plan_type and chatgpt_subscription_active_until claims at issue time. So users who upgrade/downgrade their plan kept seeing the old plan in the dashboard even after a forced refresh. After each refreshCodexAccount() call, additionally GET https://chatgpt.com/backend-api/wham/usage with the freshly-issued access_token. That endpoint returns the current plan_type and email from OpenAI's account service. Persist it to a sidecar plan-status.json next to auth.json (not into auth.json itself, since codex CLI may rewrite that file). readCodexAccount prefers the live plan_type and uses the sidecar's checked_at as subscriptionLastChecked, so the dashboard immediately reflects post-billing-event plan changes. Confirmed end-to-end: account that was billed-down from Pro to Plus now shows plus instead of stale pro after one refresh. Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.7 --- src/settings-store.ts | 81 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/src/settings-store.ts b/src/settings-store.ts index 3519439..57aa11e 100644 --- a/src/settings-store.ts +++ b/src/settings-store.ts @@ -111,6 +111,7 @@ function readCodexAccount(index: number): CodexAccountSummary | null { OPENAI_API_KEY?: string; tokens?: { id_token?: string; access_token?: string }; }>(file); + const live = readCodexLiveStatus(index); let accountId: string | null = null; let email: string | null = null; let planType: string | null = null; @@ -145,6 +146,12 @@ function readCodexAccount(index: number): CodexAccountSummary | null { } } } + // Live wham/usage data, written by refreshCodexAccount, takes precedence. + if (live) { + if (typeof live.plan_type === 'string') planType = live.plan_type; + if (typeof live.email === 'string') email = live.email; + subscriptionLastChecked = live.checked_at; + } return { index, accountId, @@ -279,6 +286,7 @@ export function updateModelConfig( const CODEX_OAUTH_TOKEN_URL = 'https://auth.openai.com/oauth/token'; const CODEX_OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'; +const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage'; interface CodexAuthFile { auth_mode?: string; @@ -292,6 +300,61 @@ interface CodexAuthFile { last_refresh?: string; } +interface CodexLiveStatus { + checked_at: string; + plan_type?: string | null; + email?: string | null; +} + +function planStatusPath(index: number): string { + if (index === 0) { + return path.join(os.homedir(), '.codex', 'plan-status.json'); + } + return path.join( + os.homedir(), + '.codex-accounts', + String(index), + 'plan-status.json', + ); +} + +function readCodexLiveStatus(index: number): CodexLiveStatus | null { + return readJson(planStatusPath(index)); +} + +function writeCodexLiveStatus(index: number, status: CodexLiveStatus): void { + const file = planStatusPath(index); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const tempPath = `${file}.tmp`; + fs.writeFileSync(tempPath, `${JSON.stringify(status, null, 2)}\n`, { + mode: 0o600, + }); + fs.renameSync(tempPath, file); +} + +async function fetchCodexLivePlanType( + accessToken: string, +): Promise<{ plan_type?: string; email?: string } | null> { + try { + const res = await fetch(CODEX_USAGE_URL, { + method: 'GET', + headers: { authorization: `Bearer ${accessToken}` }, + }); + if (!res.ok) return null; + const json = (await res.json()) as { + plan_type?: unknown; + email?: unknown; + }; + return { + plan_type: + typeof json.plan_type === 'string' ? json.plan_type : undefined, + email: typeof json.email === 'string' ? json.email : undefined, + }; + } catch { + return null; + } +} + function readCodexAuthFile(file: string): CodexAuthFile | null { return readJson(file); } @@ -354,6 +417,24 @@ export async function refreshCodexAccount( }; writeCodexAuthFile(file, updated); + // Hit chatgpt.com/backend-api/wham/usage to read the live plan_type + // (the JWT id_token's chatgpt_plan_type / *_active_until / *_last_checked + // claims are cached on OpenAI's auth0 side and don't refresh on every + // OAuth refresh_token grant). Persist the live values to a sidecar + // plan-status.json so readCodexAccount can prefer them. + const accessToken = + payload.access_token ?? data?.tokens?.access_token ?? null; + if (accessToken) { + const live = await fetchCodexLivePlanType(accessToken); + if (live) { + writeCodexLiveStatus(index, { + checked_at: new Date().toISOString(), + plan_type: live.plan_type ?? null, + email: live.email ?? null, + }); + } + } + const summary = readCodexAccount(index); if (!summary) throw new Error('failed to re-read codex account after refresh'); From e2dd12a2353edb8620d3a5968566cfeace143ffc Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 27 Apr 2026 22:47:40 +0900 Subject: [PATCH 8/8] =?UTF-8?q?Codex=20switch:=20translate=20settings-inde?= =?UTF-8?q?x=20=E2=86=94=20rotation-index=20by=20auth=20path=20(#51)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit settings-store lists codex accounts as index 0 = ~/.codex/auth.json (default) index N = ~/.codex-accounts/{N}/auth.json but codex-token-rotation only loads ~/.codex-accounts/{N} when those dirs exist (it ignores ~/.codex/auth.json in that mode), so the rotation array's indices are off-by-one vs settings indices. Old buggy behavior: - "사용중" badge pointed at the wrong account. - Clicking 전환 on UI #N called setCurrentCodexAccountIndex(N) which selected rotation array element N — typically a different ~/.codex-accounts/{N+1} entry. Effectively the UI told us a different account was now active than what the next codex spawn actually used. Fix - New findCodexAccountIndexByAuthPath(path) on the rotation module exposes a path-based lookup. - New getActiveCodexSettingsIndex() / setActiveCodexSettingsIndex(idx) in settings-store translate via codexAuthPath() ↔ findCodexAccountIndexByAuthPath(). - /api/settings/accounts.codexCurrentIndex and PUT /api/settings/accounts/codex/current both go through the settings-side translation, so UI indices are now authoritative. Co-authored-by: Claude Opus 4.7 --- src/codex-token-rotation.ts | 10 +++++++ src/settings-store.ts | 54 +++++++++++++++++++++++++++++++++++++ src/web-dashboard-server.ts | 12 ++++----- 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/src/codex-token-rotation.ts b/src/codex-token-rotation.ts index b93bbe9..aad2c6a 100644 --- a/src/codex-token-rotation.ts +++ b/src/codex-token-rotation.ts @@ -261,6 +261,16 @@ export function getCurrentCodexAccountIndex(): number { return accounts.length === 0 ? 0 : currentIndex; } +/** Find the rotation-array index that owns the given auth.json path. */ +export function findCodexAccountIndexByAuthPath( + authPath: string, +): number | null { + for (let i = 0; i < accounts.length; i += 1) { + if (accounts[i]?.authPath === authPath) return i; + } + return null; +} + /** Manually switch the active codex account. Clears its rate-limit cooldown. */ export function setCurrentCodexAccountIndex(targetIndex: number): void { if (accounts.length === 0) { diff --git a/src/settings-store.ts b/src/settings-store.ts index 57aa11e..0b2b9c0 100644 --- a/src/settings-store.ts +++ b/src/settings-store.ts @@ -15,6 +15,12 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { + findCodexAccountIndexByAuthPath, + getActiveCodexAuthPath, + setCurrentCodexAccountIndex as setRotationIndex, +} from './codex-token-rotation.js'; + export interface ClaudeAccountSummary { index: number; expiresAt: number | null; @@ -441,6 +447,54 @@ export async function refreshCodexAccount( return summary; } +/** + * settings-store lists codex accounts in an order that includes the default + * `~/.codex/auth.json` as index 0 plus `~/.codex-accounts/{N}` as index N. + * The rotation array (codex-token-rotation) only loads `~/.codex-accounts/{N}` + * when those dirs exist (it ignores `~/.codex/auth.json` in that mode), so its + * array indices are off-by-one vs. the settings indices. Translate via path. + */ +export function getActiveCodexSettingsIndex(): number | null { + const activePath = getActiveCodexAuthPath(); + if (!activePath) return null; + // Walk the same listing order as listCodexAccounts() to find the matching + // settings-store index. + if (codexAuthPath(0) === activePath && fs.existsSync(activePath)) { + return 0; + } + const dir = path.join(os.homedir(), '.codex-accounts'); + if (fs.existsSync(dir)) { + const indices = fs + .readdirSync(dir) + .filter((e) => /^\d+$/.test(e)) + .map((e) => Number.parseInt(e, 10)) + .filter((n) => Number.isFinite(n) && n >= 1) + .sort((a, b) => a - b); + for (const i of indices) { + if (codexAuthPath(i) === activePath) return i; + } + } + return null; +} + +export function setActiveCodexSettingsIndex(settingsIndex: number): void { + const file = codexAuthPath(settingsIndex); + if (!fs.existsSync(file)) { + throw new Error( + `codex auth.json not found for settings index ${settingsIndex}`, + ); + } + const rotationIndex = findCodexAccountIndexByAuthPath(file); + if (rotationIndex === null) { + throw new Error( + `codex switch: settings #${settingsIndex} (${file}) is not part of the rotation pool. ` + + `Rotation only manages accounts under ~/.codex-accounts/. ` + + `If you want to use ~/.codex/auth.json directly, remove the ~/.codex-accounts dir first.`, + ); + } + setRotationIndex(rotationIndex); +} + export async function refreshAllCodexAccounts(): Promise<{ refreshed: number[]; failed: Array<{ index: number; error: string }>; diff --git a/src/web-dashboard-server.ts b/src/web-dashboard-server.ts index 506d680..d92dd6f 100644 --- a/src/web-dashboard-server.ts +++ b/src/web-dashboard-server.ts @@ -48,6 +48,7 @@ import { } from './web-dashboard-data.js'; import { addClaudeAccountFromToken, + getActiveCodexSettingsIndex, getFastMode, getModelConfig, listClaudeAccounts, @@ -55,13 +56,10 @@ import { refreshAllCodexAccounts, refreshCodexAccount, removeAccountDirectory, + setActiveCodexSettingsIndex, updateFastMode, updateModelConfig, } from './settings-store.js'; -import { - getCurrentCodexAccountIndex, - setCurrentCodexAccountIndex, -} from './codex-token-rotation.js'; const DEFAULT_STATUS_MAX_AGE_MS = 10 * 60 * 1000; const ROOM_MESSAGE_ID_CACHE_LIMIT = 500; @@ -1393,10 +1391,10 @@ export function createWebDashboardHandler( ); } try { - setCurrentCodexAccountIndex(idx); + setActiveCodexSettingsIndex(idx); return jsonResponse({ ok: true, - codexCurrentIndex: getCurrentCodexAccountIndex(), + codexCurrentIndex: getActiveCodexSettingsIndex(), }); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -1602,7 +1600,7 @@ export function createWebDashboardHandler( return jsonResponse({ claude: listClaudeAccounts(), codex: listCodexAccounts(), - codexCurrentIndex: getCurrentCodexAccountIndex(), + codexCurrentIndex: getActiveCodexSettingsIndex(), }); }