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>
This commit is contained in:
Eyejoker
2026-03-24 03:48:36 +09:00
parent 8c98ad9faf
commit 3467e245d5
14 changed files with 2406 additions and 178 deletions

1093
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.27.1",
"better-sqlite3": "^11.8.1",
"cron-parser": "^5.5.0",
"discord.js": "^14.18.0",

View File

@@ -17,6 +17,8 @@
import fs from 'fs';
import path from 'path';
import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { fileURLToPath } from 'url';
interface ContainerInput {
@@ -65,11 +67,19 @@ interface AssistantContentBlock {
text?: string;
}
interface MementoCallToolResult {
isError?: boolean;
}
// Paths configurable via env vars.
const GROUP_DIR = process.env.EJCLAW_GROUP_DIR || '/workspace/group';
const IPC_DIR = process.env.EJCLAW_IPC_DIR || '/workspace/ipc';
// Optional: override cwd (agent works in this directory instead of GROUP_DIR)
const WORK_DIR = process.env.EJCLAW_WORK_DIR || '';
const GROUP_FOLDER = process.env.EJCLAW_GROUP_FOLDER || '';
const MEMENTO_SSE_URL = process.env.MEMENTO_MCP_SSE_URL || '';
const MEMENTO_ACCESS_KEY = process.env.MEMENTO_ACCESS_KEY || '';
const MEMENTO_TIMEOUT_MS = 4_000;
const IPC_INPUT_DIR = path.join(IPC_DIR, 'input');
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
@@ -221,6 +231,103 @@ function getSessionSummary(sessionId: string, transcriptPath: string): string |
return null;
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
}, timeoutMs);
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error) => {
clearTimeout(timer);
reject(error);
},
);
});
}
function trimSummary(summary: string, maxChars: number): string {
if (summary.length <= maxChars) return summary;
return summary.slice(0, Math.max(0, maxChars - 1)).trimEnd() + '…';
}
async function callMementoTool(
name: string,
args: Record<string, unknown>,
timeoutMs = MEMENTO_TIMEOUT_MS,
): Promise<boolean> {
if (!MEMENTO_SSE_URL || !MEMENTO_ACCESS_KEY) return false;
const transport = new SSEClientTransport(new URL(MEMENTO_SSE_URL), {
requestInit: {
headers: {
Authorization: `Bearer ${MEMENTO_ACCESS_KEY}`,
},
},
});
const client = new Client({ name: 'ejclaw-precompact', version: '1.0.0' });
try {
await withTimeout(client.connect(transport), timeoutMs, `${name}/connect`);
const result = await withTimeout(
client.callTool({
name,
arguments: args,
}) as Promise<MementoCallToolResult>,
timeoutMs,
`${name}/call`,
);
if (result.isError) {
log(`Memento tool returned error: ${name}`);
return false;
}
return true;
} catch (err) {
log(`Memento tool failed (${name}): ${err instanceof Error ? err.message : String(err)}`);
return false;
} finally {
await client.close().catch(() => {});
}
}
async function persistCompactMemory(summary: string, sessionId: string): Promise<void> {
const normalized = summary.trim();
if (!normalized) return;
const tasks: Promise<boolean>[] = [
callMementoTool('reflect', {
summary: normalized,
}),
];
if (GROUP_FOLDER) {
tasks.push(
callMementoTool('remember', {
content: trimSummary(normalized, 300),
topic: 'room-memory',
type: 'fact',
keywords: [`room:${GROUP_FOLDER}`],
source: `compact:${sessionId}`,
}),
);
}
const results = await Promise.allSettled(tasks);
const succeeded = results.filter(
(result) => result.status === 'fulfilled' && result.value,
).length;
if (succeeded > 0) {
log(`Persisted compact memory (${succeeded}/${results.length})`);
}
}
/**
* Archive the full transcript to conversations/ before compaction.
*/
@@ -258,6 +365,10 @@ function createPreCompactHook(assistantName?: string): HookCallback {
fs.writeFileSync(filePath, markdown);
log(`Archived conversation to ${filePath}`);
if (summary) {
await persistCompactMemory(summary, sessionId);
}
} catch (err) {
log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`);
}

View File

@@ -157,6 +157,7 @@ function prepareCodexSessionEnvironment(args: {
chatJid: string;
isMain: boolean;
isPairedRoom: boolean;
memoryBriefing?: string;
}): void {
const openaiKey =
args.envVars.CODEX_OPENAI_API_KEY ||
@@ -213,6 +214,7 @@ function prepareCodexSessionEnvironment(args: {
args.isPairedRoom
? readPairedRoomPrompt('codex', args.projectRoot)
: undefined,
args.memoryBriefing,
]
.filter((value): value is string => Boolean(value))
.join('\n\n---\n\n')
@@ -298,6 +300,7 @@ export function prepareGroupEnvironment(
isMain: boolean,
chatJid: string,
options?: {
memoryBriefing?: string;
runtimeTaskId?: string;
useTaskScopedSession?: boolean;
},
@@ -352,6 +355,7 @@ export function prepareGroupEnvironment(
claudePlatformPrompt,
claudePairedRoomPrompt,
globalClaudeMemory,
options?.memoryBriefing,
]
.filter((value): value is string => Boolean(value))
.join('\n\n---\n\n')
@@ -409,6 +413,7 @@ export function prepareGroupEnvironment(
chatJid,
isMain,
isPairedRoom,
memoryBriefing: options?.memoryBriefing,
});
} else {
prepareClaudeEnvironment({ env, envVars, group });

View File

@@ -27,6 +27,7 @@ const OUTPUT_END_MARKER = '---EJCLAW_OUTPUT_END---';
export interface AgentInput {
prompt: string;
sessionId?: string;
memoryBriefing?: string;
groupFolder: string;
chatJid: string;
runId?: string;
@@ -63,6 +64,7 @@ export async function runAgentProcess(
input.isMain,
input.chatJid,
{
memoryBriefing: input.memoryBriefing,
runtimeTaskId: input.runtimeTaskId,
useTaskScopedSession: input.useTaskScopedSession,
},

View File

@@ -1,4 +1,7 @@
import { spawn } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { logger } from './logger.js';
@@ -7,8 +10,24 @@ export interface ClaudeUsageData {
seven_day?: { utilization: number; resets_at: string };
}
export interface ClaudeAccountProfile {
index: number;
planType: string;
}
export interface ClaudeAccountUsage {
index: number;
isActive: boolean;
isRateLimited: boolean;
usage: ClaudeUsageData | null;
}
const CLAUDE_EXPECT_TIMEOUT_MS = 25000;
const ANSI_RE = /\u001b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
const HOST_CLAUDE_DIR = path.join(os.homedir(), '.claude');
const CLAUDE_ACCOUNTS_DIR = path.join(os.homedir(), '.claude-accounts');
let cachedProfiles: ClaudeAccountProfile[] = [];
const EXPECT_PROGRAM = `
set timeout 20
@@ -114,8 +133,59 @@ export function parseClaudeUsagePanel(rawText: string): ClaudeUsageData | null {
};
}
function listClaudeConfigDirs(): string[] {
if (!fs.existsSync(CLAUDE_ACCOUNTS_DIR)) {
return [HOST_CLAUDE_DIR];
}
const dirs = fs
.readdirSync(CLAUDE_ACCOUNTS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && /^\d+$/.test(entry.name))
.sort((a, b) => Number(a.name) - Number(b.name))
.map((entry) => path.join(CLAUDE_ACCOUNTS_DIR, entry.name));
return dirs.length > 0 ? dirs : [HOST_CLAUDE_DIR];
}
function inferClaudePlanType(configDir: string): string {
const credentialsPath = path.join(configDir, '.credentials.json');
return fs.existsSync(credentialsPath) ? 'OAuth' : 'Default';
}
function ensureProfiles(): ClaudeAccountProfile[] {
const profiles = listClaudeConfigDirs().map((configDir, index) => ({
index,
planType: inferClaudePlanType(configDir),
}));
cachedProfiles = profiles;
return profiles;
}
function readCredentialsFile(configDir: string): string | null {
try {
const credentialsPath = path.join(configDir, '.credentials.json');
if (!fs.existsSync(credentialsPath)) return null;
return fs.readFileSync(credentialsPath, 'utf8');
} catch {
return null;
}
}
function detectActiveClaudeIndex(configDirs: string[]): number {
if (configDirs.length <= 1) return 0;
const hostCredentials = readCredentialsFile(HOST_CLAUDE_DIR);
if (!hostCredentials) return 0;
const matchIndex = configDirs.findIndex(
(configDir) => readCredentialsFile(configDir) === hostCredentials,
);
return matchIndex >= 0 ? matchIndex : 0;
}
export async function fetchClaudeUsageViaCli(
binary = 'claude',
configDir?: string,
): Promise<ClaudeUsageData | null> {
return new Promise((resolve) => {
let output = '';
@@ -135,6 +205,7 @@ export async function fetchClaudeUsageViaCli(
env: {
...(process.env as Record<string, string>),
CLAUDE_BINARY: binary,
...(configDir ? { CLAUDE_CONFIG_DIR: configDir } : {}),
},
});
} catch (err) {
@@ -181,3 +252,32 @@ export async function fetchClaudeUsageViaCli(
});
});
}
export async function fetchAllClaudeProfiles(): Promise<ClaudeAccountProfile[]> {
return ensureProfiles();
}
export function getClaudeProfile(
index: number,
): ClaudeAccountProfile | undefined {
return (cachedProfiles.length > 0 ? cachedProfiles : ensureProfiles()).find(
(profile) => profile.index === index,
);
}
export async function fetchAllClaudeUsage(): Promise<ClaudeAccountUsage[]> {
const configDirs = listClaudeConfigDirs();
const profiles = ensureProfiles();
const activeIndex = detectActiveClaudeIndex(configDirs);
const usages = await Promise.all(
configDirs.map((configDir) => fetchClaudeUsageViaCli('claude', configDir)),
);
return profiles.map((profile, index) => ({
index: profile.index,
isActive: index === activeIndex,
isRateLimited: false,
usage: usages[index] || null,
}));
}

166
src/codex-token-rotation.ts Normal file
View File

@@ -0,0 +1,166 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
export interface CodexAccountState {
index: number;
planType: string;
isActive: boolean;
isRateLimited: boolean;
cachedUsagePct?: number;
resetAt?: string;
cachedUsageD7Pct?: number;
resetD7At?: string;
}
const HOST_CODEX_DIR = path.join(os.homedir(), '.codex');
const CODEX_ACCOUNTS_DIR = path.join(os.homedir(), '.codex-accounts');
let activeIndexCache: number | null = null;
const rateLimitedAccounts = new Set<number>();
const usageCache = new Map<
number,
{
cachedUsagePct?: number;
resetAt?: string;
cachedUsageD7Pct?: number;
resetD7At?: string;
}
>();
function listCodexAccountDirs(): string[] {
if (!fs.existsSync(CODEX_ACCOUNTS_DIR)) return [];
return fs
.readdirSync(CODEX_ACCOUNTS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && /^\d+$/.test(entry.name))
.sort((a, b) => Number(a.name) - Number(b.name))
.map((entry) => path.join(CODEX_ACCOUNTS_DIR, entry.name));
}
function readTextIfExists(filePath: string): string | null {
try {
if (!fs.existsSync(filePath)) return null;
return fs.readFileSync(filePath, 'utf8');
} catch {
return null;
}
}
function inferPlanType(accountDir: string): string {
try {
const authPath = path.join(accountDir, 'auth.json');
if (!fs.existsSync(authPath)) return 'API';
const raw = JSON.parse(fs.readFileSync(authPath, 'utf8')) as {
auth_mode?: string;
};
if (!raw.auth_mode) return 'API';
return raw.auth_mode
.split(/[_-]/)
.filter(Boolean)
.map((part) => part[0].toUpperCase() + part.slice(1))
.join(' ');
} catch {
return 'API';
}
}
function resolveActiveIndex(accountDirs: string[]): number {
if (accountDirs.length <= 1) return 0;
if (activeIndexCache !== null && activeIndexCache < accountDirs.length) {
return activeIndexCache;
}
const hostAuth = readTextIfExists(path.join(HOST_CODEX_DIR, 'auth.json'));
if (hostAuth) {
const matchIndex = accountDirs.findIndex(
(accountDir) => readTextIfExists(path.join(accountDir, 'auth.json')) === hostAuth,
);
if (matchIndex >= 0) {
activeIndexCache = matchIndex;
return matchIndex;
}
}
activeIndexCache = 0;
return 0;
}
function copyIfExists(src: string, dst: string): void {
if (!fs.existsSync(src)) return;
fs.mkdirSync(path.dirname(dst), { recursive: true });
fs.copyFileSync(src, dst);
}
function syncHostCodexAccount(accountDir: string): void {
copyIfExists(path.join(accountDir, 'auth.json'), path.join(HOST_CODEX_DIR, 'auth.json'));
}
export function getAllCodexAccounts(): CodexAccountState[] {
const accountDirs = listCodexAccountDirs();
if (accountDirs.length === 0) {
return [
{
index: 0,
planType: 'API',
isActive: true,
isRateLimited: false,
},
];
}
const activeIndex = resolveActiveIndex(accountDirs);
return accountDirs.map((accountDir, index) => ({
index,
planType: inferPlanType(accountDir),
isActive: index === activeIndex,
isRateLimited: rateLimitedAccounts.has(index),
...usageCache.get(index),
}));
}
export function getCodexAccountCount(): number {
return getAllCodexAccounts().length;
}
export function rotateCodexToken(_reason?: string): boolean {
const accountDirs = listCodexAccountDirs();
if (accountDirs.length <= 1) return false;
const activeIndex = resolveActiveIndex(accountDirs);
rateLimitedAccounts.add(activeIndex);
const candidates = accountDirs.map((_, index) => index);
const nextIndex =
candidates.find(
(index) => index !== activeIndex && !rateLimitedAccounts.has(index),
) ??
candidates.find((index) => index !== activeIndex);
if (nextIndex === undefined) return false;
syncHostCodexAccount(accountDirs[nextIndex]);
activeIndexCache = nextIndex;
return true;
}
export function markCodexTokenHealthy(): void {
const accountDirs = listCodexAccountDirs();
const activeIndex = resolveActiveIndex(accountDirs);
rateLimitedAccounts.delete(activeIndex);
}
export function updateCodexAccountUsage(
pct: number,
resetAt: string | undefined,
index: number,
d7Pct?: number,
resetD7At?: string,
): void {
usageCache.set(index, {
cachedUsagePct: pct,
resetAt,
cachedUsageD7Pct: d7Pct,
resetD7At,
});
}

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import {
buildRoomMemoryKey,
formatRoomMemoryBriefing,
} from './memento-client.js';
describe('memento-client helpers', () => {
it('builds a stable room memory key from the group folder', () => {
expect(buildRoomMemoryKey('ejclaw')).toBe('room:ejclaw');
});
it('formats recalled fragments into a compact session briefing', () => {
const briefing = formatRoomMemoryBriefing('room:ejclaw', [
{
id: 'frag-1',
content: '사용자는 세션 리셋 후에도 방 맥락이 이어지길 원함.',
type: 'decision',
topic: 'room-memory',
},
{
id: 'frag-2',
content: '자동 recall/reflect를 호스트가 책임지는 방향으로 합의함.',
type: 'fact',
},
]);
expect(briefing).toContain('## Shared Room Memory');
expect(briefing).toContain('room:ejclaw');
expect(briefing).toContain(
'[decision / room-memory] 사용자는 세션 리셋 후에도 방 맥락이 이어지길 원함.',
);
expect(briefing).toContain(
'[fact] 자동 recall/reflect를 호스트가 책임지는 방향으로 합의함.',
);
});
it('trims overly long briefings to the configured max length', () => {
const briefing = formatRoomMemoryBriefing(
'room:ejclaw',
[
{
id: 'frag-1',
content: 'a'.repeat(300),
type: 'fact',
},
],
120,
);
expect(briefing).toBeDefined();
expect(briefing!.length).toBeLessThanOrEqual(120);
expect(briefing!.endsWith('…')).toBe(true);
});
});

223
src/memento-client.ts Normal file
View File

@@ -0,0 +1,223 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { readEnvFile } from './env.js';
import { logger } from './logger.js';
const DEFAULT_TIMEOUT_MS = 4_000;
const DEFAULT_PAGE_SIZE = 6;
const DEFAULT_TOKEN_BUDGET = 1_200;
const DEFAULT_MAX_BRIEFING_CHARS = 2_000;
interface MementoConfig {
sseUrl: string;
accessKey: string;
}
interface MementoContentBlock {
type?: string;
text?: string;
}
interface MementoCallToolResult {
content?: MementoContentBlock[];
isError?: boolean;
}
interface MementoRecallFragment {
id: string;
content: string;
topic?: string;
type?: string;
importance?: number;
}
interface MementoRecallResponse {
success?: boolean;
fragments?: MementoRecallFragment[];
}
let cachedConfig: MementoConfig | null | undefined;
function getMementoConfig(): MementoConfig | null {
if (cachedConfig !== undefined) return cachedConfig;
const env = readEnvFile(['MEMENTO_MCP_SSE_URL', 'MEMENTO_ACCESS_KEY']);
const sseUrl = process.env.MEMENTO_MCP_SSE_URL || env.MEMENTO_MCP_SSE_URL;
const accessKey = process.env.MEMENTO_ACCESS_KEY || env.MEMENTO_ACCESS_KEY;
cachedConfig =
sseUrl && accessKey
? {
sseUrl,
accessKey,
}
: null;
return cachedConfig;
}
function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
label: string,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
}, timeoutMs);
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error) => {
clearTimeout(timer);
reject(error);
},
);
});
}
function extractToolText(result: MementoCallToolResult): string | null {
if (!Array.isArray(result.content)) return null;
const text = result.content
.filter(
(block): block is MementoContentBlock & { text: string } =>
block.type === 'text' && typeof block.text === 'string',
)
.map((block) => block.text)
.join('\n')
.trim();
return text || null;
}
function parseToolJson<T>(
result: MementoCallToolResult,
toolName: string,
): T | null {
const text = extractToolText(result);
if (!text) return null;
try {
return JSON.parse(text) as T;
} catch (error) {
logger.warn(
{ toolName, error, text },
'Failed to parse Memento tool response JSON',
);
return null;
}
}
async function callMementoTool<T>(
name: string,
args: Record<string, unknown>,
timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<T | null> {
const config = getMementoConfig();
if (!config) return null;
const transport = new SSEClientTransport(new URL(config.sseUrl), {
requestInit: {
headers: {
Authorization: `Bearer ${config.accessKey}`,
},
},
});
const client = new Client({ name: 'ejclaw-host', version: '1.0.0' });
try {
await withTimeout(client.connect(transport), timeoutMs, `${name}/connect`);
const result = await withTimeout(
client.callTool({
name,
arguments: args,
}) as Promise<MementoCallToolResult>,
timeoutMs,
`${name}/call`,
);
if (result.isError) {
logger.warn({ toolName: name, args }, 'Memento tool returned an error');
return null;
}
return parseToolJson<T>(result, name);
} catch (error) {
logger.warn({ toolName: name, args, error }, 'Memento tool call failed');
return null;
} finally {
await client.close().catch(() => {});
}
}
function trimToMaxChars(text: string, maxChars: number): string {
if (text.length <= maxChars) return text;
return text.slice(0, Math.max(0, maxChars - 1)).trimEnd() + '…';
}
export function buildRoomMemoryKey(groupFolder: string): string {
return `room:${groupFolder}`;
}
export function formatRoomMemoryBriefing(
roomKey: string,
fragments: MementoRecallFragment[],
maxChars = DEFAULT_MAX_BRIEFING_CHARS,
): string | undefined {
if (fragments.length === 0) return undefined;
const lines = fragments
.map((fragment) => {
const meta = [fragment.type, fragment.topic].filter(Boolean).join(' / ');
const prefix = meta ? `- [${meta}] ` : '- ';
return `${prefix}${fragment.content.trim()}`;
})
.filter(Boolean);
if (lines.length === 0) return undefined;
const text = [
'## Shared Room Memory',
`Room key: \`${roomKey}\``,
'Treat this as background context for a fresh session start. The current conversation always takes precedence.',
...lines,
].join('\n');
return trimToMaxChars(text, maxChars);
}
export async function buildRoomMemoryBriefing(args: {
groupFolder: string;
groupName: string;
timeoutMs?: number;
maxChars?: number;
}): Promise<string | undefined> {
const roomKey = buildRoomMemoryKey(args.groupFolder);
const recallResponse = await callMementoTool<MementoRecallResponse>(
'recall',
{
text: `shared room memory for ${args.groupName} (${roomKey})`,
keywords: [roomKey],
pageSize: DEFAULT_PAGE_SIZE,
tokenBudget: DEFAULT_TOKEN_BUDGET,
includeLinks: false,
excludeSeen: false,
},
args.timeoutMs ?? DEFAULT_TIMEOUT_MS,
);
if (!recallResponse?.success || !recallResponse.fragments?.length) {
return undefined;
}
return formatRoomMemoryBriefing(
roomKey,
recallResponse.fragments,
args.maxChars ?? DEFAULT_MAX_BRIEFING_CHARS,
);
}

View File

@@ -0,0 +1,138 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./agent-runner.js', () => ({
runAgentProcess: vi.fn(),
writeGroupsSnapshot: vi.fn(),
writeTasksSnapshot: vi.fn(),
}));
vi.mock('./available-groups.js', () => ({
listAvailableGroups: vi.fn(() => []),
}));
vi.mock('./config.js', () => ({
DATA_DIR: '/tmp/ejclaw-test-data',
}));
vi.mock('./db.js', () => ({
getAllTasks: vi.fn(() => []),
}));
vi.mock('./logger.js', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('./provider-fallback.js', () => ({
detectFallbackTrigger: vi.fn(() => ({
shouldFallback: false,
reason: '',
})),
getActiveProvider: vi.fn(() => 'claude'),
getFallbackEnvOverrides: vi.fn(() => ({})),
getFallbackProviderName: vi.fn(() => 'fallback'),
hasGroupProviderOverride: vi.fn(() => false),
isFallbackEnabled: vi.fn(() => false),
markPrimaryCooldown: vi.fn(),
}));
vi.mock('./session-recovery.js', () => ({
shouldResetSessionOnAgentFailure: vi.fn(() => false),
}));
vi.mock('./memento-client.js', () => ({
buildRoomMemoryBriefing: vi.fn(),
}));
import { runAgentProcess } from './agent-runner.js';
import { buildRoomMemoryBriefing } from './memento-client.js';
import { runAgentForGroup } from './message-agent-executor.js';
import type { RegisteredGroup } from './types.js';
const testGroup: RegisteredGroup = {
name: 'Test Group',
folder: 'test-group',
trigger: '@Andy',
added_at: new Date().toISOString(),
};
const deps = {
assistantName: 'Andy',
queue: {
registerProcess: vi.fn(),
},
getRegisteredGroups: vi.fn(() => ({})),
getSessions: vi.fn(() => ({})),
persistSession: vi.fn(),
clearSession: vi.fn(),
};
describe('runAgentForGroup', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(runAgentProcess).mockResolvedValue({
status: 'success',
result: 'ok',
newSessionId: 'session-123',
});
vi.mocked(buildRoomMemoryBriefing).mockResolvedValue(
'## Shared Room Memory\n- remembered context',
);
});
it('injects a room memory briefing when starting a fresh session', async () => {
deps.getSessions.mockReturnValue({});
const result = await runAgentForGroup(deps, {
group: testGroup,
prompt: 'hello',
chatJid: 'group@test',
runId: 'run-1',
});
expect(result).toBe('success');
expect(buildRoomMemoryBriefing).toHaveBeenCalledWith({
groupFolder: 'test-group',
groupName: 'Test Group',
});
expect(runAgentProcess).toHaveBeenCalledWith(
testGroup,
expect.objectContaining({
prompt: 'hello',
sessionId: undefined,
memoryBriefing: '## Shared Room Memory\n- remembered context',
}),
expect.any(Function),
undefined,
undefined,
);
});
it('skips the room memory briefing for existing sessions', async () => {
deps.getSessions.mockReturnValue({ 'test-group': 'session-existing' });
const result = await runAgentForGroup(deps, {
group: testGroup,
prompt: 'hello again',
chatJid: 'group@test',
runId: 'run-2',
});
expect(result).toBe('success');
expect(buildRoomMemoryBriefing).not.toHaveBeenCalled();
expect(runAgentProcess).toHaveBeenCalledWith(
testGroup,
expect.objectContaining({
prompt: 'hello again',
sessionId: 'session-existing',
memoryBriefing: undefined,
}),
expect.any(Function),
undefined,
undefined,
);
});
});

View File

@@ -11,6 +11,7 @@ import { DATA_DIR } from './config.js';
import { getAllTasks } from './db.js';
import { GroupQueue } from './group-queue.js';
import { logger } from './logger.js';
import { buildRoomMemoryBriefing } from './memento-client.js';
import {
detectFallbackTrigger,
getActiveProvider,
@@ -21,6 +22,16 @@ import {
markPrimaryCooldown,
} from './provider-fallback.js';
import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
import {
rotateCodexToken,
getCodexAccountCount,
markCodexTokenHealthy,
} from './codex-token-rotation.js';
import {
rotateToken,
getTokenCount,
markTokenHealthy,
} from './token-rotation.js';
import type { RegisteredGroup } from './types.js';
export interface MessageAgentExecutorDeps {
@@ -32,6 +43,14 @@ export interface MessageAgentExecutorDeps {
clearSession: (groupFolder: string) => void;
}
function isClaudeUsageExhaustedMessage(text: string): boolean {
const normalized = text.trim();
return (
/^you(?:'re| are) out of extra usage\b/i.test(normalized) ||
/^you(?:'ve| have) hit your limit\b/i.test(normalized)
);
}
export async function runAgentForGroup(
deps: MessageAgentExecutorDeps,
args: {
@@ -48,6 +67,12 @@ export async function runAgentForGroup(
(group.agentType || 'claude-code') === 'claude-code';
const sessions = deps.getSessions();
const sessionId = sessions[group.folder];
const memoryBriefing = sessionId
? undefined
: await buildRoomMemoryBriefing({
groupFolder: group.folder,
groupName: group.name,
}).catch(() => undefined);
const tasks = getAllTasks(group.agentType || 'claude-code');
writeTasksSnapshot(
@@ -86,6 +111,7 @@ export async function runAgentForGroup(
const agentInput = {
prompt,
sessionId,
memoryBriefing,
groupFolder: group.folder,
chatJid,
runId,
@@ -127,6 +153,20 @@ export async function runAgentForGroup(
) {
resetSessionRequested = true;
}
if (
canFallback &&
provider === 'claude' &&
output.status === 'success' &&
!sawOutput &&
!streamedTriggerReason &&
typeof output.result === 'string' &&
isClaudeUsageExhaustedMessage(output.result)
) {
streamedTriggerReason = {
reason: 'usage-exhausted',
};
return;
}
if (output.result !== null && output.result !== undefined) {
sawOutput = true;
} else if (
@@ -137,7 +177,6 @@ export async function runAgentForGroup(
sawSuccessNullResultWithoutOutput = true;
}
if (
provider === 'claude' &&
output.status === 'error' &&
!sawOutput &&
!streamedTriggerReason
@@ -299,6 +338,18 @@ export async function runAgentForGroup(
}
: detectFallbackTrigger(errMsg);
if (trigger.shouldFallback) {
// Try rotating token before falling back to another provider
if (getTokenCount() > 1 && rotateToken(errMsg)) {
logger.info(
{ chatJid, group: group.name, runId, reason: trigger.reason },
'Rate-limited, retrying with rotated token',
);
const retryAttempt = await runAttempt('claude');
if (!retryAttempt.error) {
markTokenHealthy();
return 'success';
}
}
return runFallbackAttempt(trigger.reason, trigger.retryAfterMs);
}
}
@@ -332,6 +383,19 @@ export async function runAgentForGroup(
return 'error';
}
if (
canFallback &&
provider === 'claude' &&
!primaryAttempt.sawOutput &&
primaryAttempt.streamedTriggerReason &&
output.status !== 'error'
) {
return runFallbackAttempt(
primaryAttempt.streamedTriggerReason.reason,
primaryAttempt.streamedTriggerReason.retryAfterMs,
);
}
if (
canFallback &&
provider === 'claude' &&
@@ -362,10 +426,40 @@ export async function runAgentForGroup(
}
: detectFallbackTrigger(output.error);
if (trigger.shouldFallback) {
if (getTokenCount() > 1 && rotateToken(output.error ?? undefined)) {
logger.info(
{ chatJid, group: group.name, runId, reason: trigger.reason },
'Rate-limited (output error), retrying with rotated token',
);
const retryAttempt = await runAttempt('claude');
if (!retryAttempt.error) {
markTokenHealthy();
return 'success';
}
}
return runFallbackAttempt(trigger.reason, trigger.retryAfterMs);
}
}
// Codex rate-limit rotation (non-Claude agents)
if (!isClaudeCodeAgent && getCodexAccountCount() > 1) {
const trigger = detectFallbackTrigger(output.error);
if (
trigger.shouldFallback &&
rotateCodexToken(output.error ?? undefined)
) {
logger.info(
{ chatJid, group: group.name, runId, reason: trigger.reason },
'Codex rate-limited, retrying with rotated account',
);
const retryAttempt = await runAttempt('codex');
if (!retryAttempt.error) {
markCodexTokenHealthy();
return 'success';
}
}
}
logger.error(
{
group: group.name,
@@ -379,5 +473,29 @@ export async function runAgentForGroup(
return 'error';
}
// Codex may report success but have streamed a rate-limit error.
// Rotate token and retry immediately with the new account.
if (
!isClaudeCodeAgent &&
primaryAttempt.streamedTriggerReason &&
getCodexAccountCount() > 1 &&
rotateCodexToken(output.error ?? undefined)
) {
logger.info(
{
chatJid,
group: group.name,
runId,
reason: primaryAttempt.streamedTriggerReason.reason,
},
'Codex rate-limited (streamed), retrying with rotated account',
);
const retryAttempt = await runAttempt('codex');
if (!retryAttempt.error) {
markCodexTokenHealthy();
return 'success';
}
}
return 'success';
}

View File

@@ -1957,6 +1957,86 @@ describe('createMessageRuntime', () => {
);
});
it('retries with the fallback provider when Claude returns only a usage exhaustion banner', async () => {
const chatJid = 'group@test';
const group = makeGroup('claude-code');
const channel = makeChannel(chatJid);
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-1',
chat_jid: chatJid,
sender: 'user@test',
sender_name: 'User',
content: 'hello',
timestamp: '2026-03-24T00:00:00.000Z',
},
]);
vi.mocked(agentRunner.runAgentProcess)
.mockImplementationOnce(async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
phase: 'final',
result: "You're out of extra usage · resets 4am (Asia/Seoul)",
});
return {
status: 'success',
result: null,
};
})
.mockImplementationOnce(async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
phase: 'final',
result: 'usage fallback 응답입니다.',
});
return {
status: 'success',
result: null,
};
});
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => ({}),
saveState: vi.fn(),
persistSession: vi.fn(),
clearSession: vi.fn(),
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-fallback-usage-exhausted',
reason: 'messages',
});
expect(result).toBe(true);
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(2);
expect(providerFallback.markPrimaryCooldown).toHaveBeenCalledWith(
'usage-exhausted',
undefined,
);
expect(channel.sendMessage).toHaveBeenCalledTimes(1);
expect(channel.sendMessage).toHaveBeenCalledWith(
chatJid,
'usage fallback 응답입니다.',
);
});
it('retries with the fallback provider when Claude ends with success-null-result before any output', async () => {
const chatJid = 'group@test';
const group = makeGroup('claude-code');

62
src/token-rotation.ts Normal file
View File

@@ -0,0 +1,62 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
const HOST_CLAUDE_DIR = path.join(os.homedir(), '.claude');
const CLAUDE_ACCOUNTS_DIR = path.join(os.homedir(), '.claude-accounts');
let activeIndex = 0;
const rateLimitedAccounts = new Set<number>();
function listClaudeAccounts(): string[] {
if (!fs.existsSync(CLAUDE_ACCOUNTS_DIR)) return [];
return fs
.readdirSync(CLAUDE_ACCOUNTS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && /^\d+$/.test(entry.name))
.sort((a, b) => Number(a.name) - Number(b.name))
.map((entry) => path.join(CLAUDE_ACCOUNTS_DIR, entry.name));
}
function copyIfExists(src: string, dst: string): void {
if (!fs.existsSync(src)) return;
fs.mkdirSync(path.dirname(dst), { recursive: true });
fs.copyFileSync(src, dst);
}
function syncHostClaudeAccount(accountDir: string): void {
copyIfExists(
path.join(accountDir, '.credentials.json'),
path.join(HOST_CLAUDE_DIR, '.credentials.json'),
);
copyIfExists(path.join(accountDir, 'settings.json'), path.join(HOST_CLAUDE_DIR, 'settings.json'));
}
export function getTokenCount(): number {
const accounts = listClaudeAccounts();
return accounts.length > 0 ? accounts.length : 1;
}
export function rotateToken(_reason?: string): boolean {
const accounts = listClaudeAccounts();
if (accounts.length <= 1) return false;
rateLimitedAccounts.add(activeIndex);
const candidates = accounts.map((_, index) => index);
const nextIndex =
candidates.find(
(index) => index !== activeIndex && !rateLimitedAccounts.has(index),
) ??
candidates.find((index) => index !== activeIndex);
if (nextIndex === undefined) return false;
syncHostClaudeAccount(accounts[nextIndex]);
activeIndex = nextIndex;
return true;
}
export function markTokenHealthy(): void {
rateLimitedAccounts.delete(activeIndex);
}

View File

@@ -5,9 +5,15 @@ import path from 'path';
import { STATUS_SHOW_ROOMS, USAGE_DASHBOARD_ENABLED } from './config.js';
import {
fetchClaudeUsageViaCli,
type ClaudeUsageData,
fetchAllClaudeUsage,
fetchAllClaudeProfiles,
getClaudeProfile,
type ClaudeAccountUsage,
} from './claude-usage.js';
import {
getAllCodexAccounts,
updateCodexAccountUsage,
} from './codex-token-rotation.js';
import {
composeDashboardContent,
formatElapsed,
@@ -16,7 +22,6 @@ import {
renderCategorizedRoomSections,
} from './dashboard-render.js';
import { getAllChats, updateRegisteredGroupName } from './db.js';
import { readEnvFile } from './env.js';
import type { GroupQueue } from './group-queue.js';
import { logger } from './logger.js';
import {
@@ -61,11 +66,8 @@ const STATUS_SNAPSHOT_MAX_AGE_MS = 60000;
let statusMessageId: string | null = null;
let cachedUsageContent = '';
let cachedClaudeUsageData: ClaudeUsageData | null = null;
let cachedClaudeAccounts: ClaudeAccountUsage[] = [];
let usageUpdateInProgress = false;
let usageApiBackoffUntil = 0;
let usageApi429Streak = 0;
let usageApiPollingDisabled = false;
let channelMetaCache = new Map<string, ChannelMeta>();
let channelMetaLastRefresh = 0;
@@ -85,6 +87,25 @@ function formatResetKST(value: string | number): string {
}
}
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(),
@@ -108,22 +129,6 @@ export async function purgeDashboardChannel(
}
}
function parseRetryAfterMs(retryAfter: string | null): number | null {
if (!retryAfter) return null;
const seconds = Number(retryAfter);
if (Number.isFinite(seconds) && seconds > 0) {
return seconds * 1000;
}
const absolute = Date.parse(retryAfter);
if (!Number.isNaN(absolute)) {
return Math.max(0, absolute - Date.now());
}
return null;
}
async function refreshChannelMeta(
opts: UnifiedDashboardOptions,
): Promise<void> {
@@ -339,86 +344,9 @@ function buildStatusContent(): string {
return `${header}\n\n${sections}`;
}
async function fetchClaudeUsage(): Promise<ClaudeUsageData | null> {
if (usageApiPollingDisabled) {
logger.debug('Skipping usage API call (polling disabled for this process)');
return null;
}
if (Date.now() < usageApiBackoffUntil) {
logger.debug('Skipping usage API call (backoff active)');
return null;
}
const cliUsage = await fetchClaudeUsageViaCli();
if (cliUsage) {
usageApi429Streak = 0;
return cliUsage;
}
try {
const envToken = readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN']);
let token =
process.env.CLAUDE_CODE_OAUTH_TOKEN ||
envToken.CLAUDE_CODE_OAUTH_TOKEN ||
'';
if (!token) {
const configDir =
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
const credsPath = path.join(configDir, '.credentials.json');
if (!fs.existsSync(credsPath)) return null;
const creds = JSON.parse(fs.readFileSync(credsPath, 'utf-8'));
token = creds?.claudeAiOauth?.accessToken || '';
}
if (!token) return null;
const res = await fetch('https://api.anthropic.com/api/oauth/usage', {
headers: {
Authorization: `Bearer ${token}`,
'anthropic-beta': 'oauth-2025-04-20',
},
});
if (!res.ok) {
const body = await res.text().catch(() => '');
if (res.status === 429) {
const retryAfter = res.headers.get('retry-after');
const retryAfterMs = parseRetryAfterMs(retryAfter);
const backoffMs = Math.max(600_000, retryAfterMs ?? 0);
usageApi429Streak += 1;
usageApiBackoffUntil = Date.now() + backoffMs;
if (usageApi429Streak >= 3) {
usageApiPollingDisabled = true;
}
logger.warn(
{
status: 429,
retryAfter,
retryAfterMs,
backoffMs,
consecutive429s: usageApi429Streak,
pollingDisabled: usageApiPollingDisabled,
body: body.slice(0, 200),
},
usageApiPollingDisabled
? 'Usage API rate limited repeatedly (429), disabling usage polling for this process'
: 'Usage API rate limited (429), backing off',
);
} else {
logger.warn(
{ status: res.status, body: body.slice(0, 200) },
'Usage API returned non-OK status',
);
}
return null;
}
usageApi429Streak = 0;
return (await res.json()) as ClaudeUsageData;
} catch (err) {
logger.debug({ err }, 'Usage API fetch failed');
return null;
}
}
async function fetchCodexUsage(): Promise<CodexRateLimit[] | null> {
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';
@@ -441,17 +369,22 @@ async function fetchCodexUsage(): Promise<CodexRateLimit[] | null> {
const timer = setTimeout(() => finish(null), 20_000);
try {
proc = spawn(codexBin, ['app-server'], {
stdio: ['pipe', 'pipe', 'pipe'],
env: {
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);
@@ -509,32 +442,76 @@ async function fetchCodexUsage(): Promise<CodexRateLimit[] | null> {
});
}
async function buildUsageContent(): Promise<string> {
const activeSnapshots = readStatusSnapshots(STATUS_SNAPSHOT_MAX_AGE_MS);
const hasActiveClaudeWork = activeSnapshots.some(
(snapshot) =>
snapshot.agentType === 'claude-code' &&
snapshot.entries.some(
(entry) => entry.status === 'processing' || entry.status === 'waiting',
),
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 [liveClaudeUsage, codexUsage] = await Promise.all([
shouldFetchClaudeUsage && !hasActiveClaudeWork
? fetchClaudeUsage()
: Promise.resolve(null),
fetchCodexUsage(),
]);
const claudeUsage = shouldFetchClaudeUsage
? liveClaudeUsage || cachedClaudeUsageData
: null;
const claudeUsageIsCached =
shouldFetchClaudeUsage && !liveClaudeUsage && !!cachedClaudeUsageData;
if (shouldFetchClaudeUsage && liveClaudeUsage) {
cachedClaudeUsageData = liveClaudeUsage;
const 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) => {
@@ -542,36 +519,55 @@ async function buildUsageContent(): Promise<string> {
return '█'.repeat(filled) + '░'.repeat(10 - filled);
};
type UsageRow = {
name: string;
h5pct: number;
h5reset: string;
d7pct: number;
d7reset: string;
};
const rows: UsageRow[] = [];
if (claudeUsage) {
const h5 = claudeUsage.five_hour;
const d7 = claudeUsage.seven_day;
rows.push({
name: claudeUsageIsCached ? 'Claude*' : 'Claude',
h5pct: h5
? h5.utilization > 1
? Math.round(h5.utilization)
: Math.round(h5.utilization * 100)
: -1,
h5reset: h5 ? formatResetKST(h5.resets_at) : '',
d7pct: d7
? d7.utilization > 1
? Math.round(d7.utilization)
: Math.round(d7.utilization * 100)
: -1,
d7reset: d7 ? formatResetKST(d7.resets_at) : '',
});
if (shouldFetchClaudeUsage) {
cachedClaudeAccounts = mergeClaudeDashboardAccounts(
liveClaudeAccounts,
cachedClaudeAccounts,
);
rows.push(...buildClaudeUsageRows(cachedClaudeAccounts));
}
if (codexUsage && Array.isArray(codexUsage)) {
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,
@@ -589,8 +585,15 @@ async function buildUsageContent(): Promise<string> {
}
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(' 5-Hour 7-Day');
lines.push(`${' '.repeat(maxNameWidth)}5-Hour 7-Day`);
for (const row of rows) {
const h5 =
row.h5pct >= 0
@@ -600,21 +603,17 @@ async function buildUsageContent(): Promise<string> {
row.d7pct >= 0
? `${bar(row.d7pct)} ${String(row.d7pct).padStart(3)}%`
: ' — ';
lines.push(`${row.name.padEnd(8)}${h5} ${d7}`);
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('_조회 불가_');
}
if (shouldFetchClaudeUsage && usageApiPollingDisabled) {
lines.push(
'_* Claude 사용량 조회는 반복된 429로 이번 프로세스에서 일시 중지_',
);
}
if (claudeUsageIsCached) {
lines.push('_* Claude 사용량은 작업 중일 때는 캐시값 유지_');
}
lines.push('');
lines.push('🖥️ *서버*');
@@ -670,13 +669,80 @@ function buildUnifiedDashboardContent(): string {
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 {
/* keep previous cache */
} catch (err) {
logger.warn({ err }, 'Failed to build usage content');
} finally {
usageUpdateInProgress = false;
}
@@ -695,6 +761,7 @@ export async function startUnifiedDashboard(
}
if (isRenderer) {
await fetchAllClaudeProfiles();
await refreshUsageCache();
}
@@ -709,6 +776,13 @@ export async function startUnifiedDashboard(
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;
}
@@ -720,7 +794,7 @@ export async function startUnifiedDashboard(
if (id) statusMessageId = id;
}
} catch (err) {
logger.debug({ err }, 'Dashboard update failed');
logger.warn({ err }, 'Dashboard update failed');
statusMessageId = null;
}
};
@@ -730,6 +804,16 @@ export async function startUnifiedDashboard(
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(