refactor: extract dashboard and task helpers
This commit is contained in:
@@ -23,7 +23,7 @@ import {
|
|||||||
readPairedRoomPrompt,
|
readPairedRoomPrompt,
|
||||||
readPlatformPrompt,
|
readPlatformPrompt,
|
||||||
} from './platform-prompts.js';
|
} from './platform-prompts.js';
|
||||||
import { RegisteredGroup } from './types.js';
|
import { AgentType, RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
// Sentinel markers for robust output parsing (must match agent-runner)
|
// Sentinel markers for robust output parsing (must match agent-runner)
|
||||||
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
|
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
|
||||||
@@ -49,6 +49,278 @@ export interface AgentOutput {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncDirectoryEntries(sources: string[], destination: string): void {
|
||||||
|
for (const source of sources) {
|
||||||
|
if (!fs.existsSync(source)) continue;
|
||||||
|
for (const entry of fs.readdirSync(source)) {
|
||||||
|
const srcPath = path.join(source, entry);
|
||||||
|
const dstPath = path.join(destination, entry);
|
||||||
|
if (fs.statSync(srcPath).isDirectory()) {
|
||||||
|
fs.cpSync(srcPath, dstPath, { recursive: true });
|
||||||
|
} else {
|
||||||
|
fs.mkdirSync(destination, { recursive: true });
|
||||||
|
fs.copyFileSync(srcPath, dstPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureClaudeSessionSettings(groupSessionsDir: string): void {
|
||||||
|
const settingsFile = path.join(groupSessionsDir, 'settings.json');
|
||||||
|
if (fs.existsSync(settingsFile)) return;
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
settingsFile,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
||||||
|
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
|
||||||
|
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
) + '\n',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBaseRunnerEnv(args: {
|
||||||
|
group: RegisteredGroup;
|
||||||
|
chatJid: string;
|
||||||
|
isMain: boolean;
|
||||||
|
groupDir: string;
|
||||||
|
groupIpcDir: string;
|
||||||
|
globalDir: string;
|
||||||
|
groupSessionsDir: string;
|
||||||
|
agentType: AgentType;
|
||||||
|
envVars: Record<string, string>;
|
||||||
|
}): Record<string, string> {
|
||||||
|
const cleanEnv = { ...(process.env as Record<string, string>) };
|
||||||
|
for (const [key, value] of Object.entries(args.envVars)) {
|
||||||
|
if (value && !cleanEnv[key]) cleanEnv[key] = value;
|
||||||
|
}
|
||||||
|
delete cleanEnv.CLAUDECODE;
|
||||||
|
delete cleanEnv.CLAUDE_CODE_ENTRYPOINT;
|
||||||
|
|
||||||
|
const nodeBin = path.dirname(process.execPath);
|
||||||
|
const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin');
|
||||||
|
const currentPath = cleanEnv.PATH || '/usr/local/bin:/usr/bin:/bin';
|
||||||
|
const extraPaths = [nodeBin, npmGlobalBin].filter(
|
||||||
|
(candidate) =>
|
||||||
|
!currentPath.includes(candidate) && fs.existsSync(candidate),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...cleanEnv,
|
||||||
|
PATH:
|
||||||
|
extraPaths.length > 0
|
||||||
|
? `${extraPaths.join(':')}:${currentPath}`
|
||||||
|
: currentPath,
|
||||||
|
TZ: TIMEZONE,
|
||||||
|
HOME: os.homedir(),
|
||||||
|
NANOCLAW_GROUP_DIR: args.groupDir,
|
||||||
|
NANOCLAW_IPC_DIR: args.groupIpcDir,
|
||||||
|
NANOCLAW_GLOBAL_DIR: args.globalDir,
|
||||||
|
...(args.group.workDir ? { NANOCLAW_WORK_DIR: args.group.workDir } : {}),
|
||||||
|
NANOCLAW_CHAT_JID: args.chatJid,
|
||||||
|
NANOCLAW_GROUP_FOLDER: args.group.folder,
|
||||||
|
NANOCLAW_IS_MAIN: args.isMain ? '1' : '0',
|
||||||
|
NANOCLAW_AGENT_TYPE: args.agentType,
|
||||||
|
CLAUDE_CONFIG_DIR: args.groupSessionsDir,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareClaudeEnvironment(args: {
|
||||||
|
env: Record<string, string>;
|
||||||
|
envVars: Record<string, string>;
|
||||||
|
group: RegisteredGroup;
|
||||||
|
}): void {
|
||||||
|
if (args.envVars.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY) {
|
||||||
|
args.env.ANTHROPIC_API_KEY =
|
||||||
|
args.envVars.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY || '';
|
||||||
|
}
|
||||||
|
if (args.envVars.ANTHROPIC_AUTH_TOKEN || process.env.ANTHROPIC_AUTH_TOKEN) {
|
||||||
|
args.env.ANTHROPIC_AUTH_TOKEN =
|
||||||
|
args.envVars.ANTHROPIC_AUTH_TOKEN ||
|
||||||
|
process.env.ANTHROPIC_AUTH_TOKEN ||
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
if (args.envVars.ANTHROPIC_BASE_URL || process.env.ANTHROPIC_BASE_URL) {
|
||||||
|
args.env.ANTHROPIC_BASE_URL =
|
||||||
|
args.envVars.ANTHROPIC_BASE_URL || process.env.ANTHROPIC_BASE_URL || '';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
args.envVars.CLAUDE_CODE_OAUTH_TOKEN ||
|
||||||
|
process.env.CLAUDE_CODE_OAUTH_TOKEN
|
||||||
|
) {
|
||||||
|
args.env.CLAUDE_CODE_OAUTH_TOKEN =
|
||||||
|
args.envVars.CLAUDE_CODE_OAUTH_TOKEN ||
|
||||||
|
process.env.CLAUDE_CODE_OAUTH_TOKEN ||
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
for (const key of [
|
||||||
|
'CLAUDE_MODEL',
|
||||||
|
'CLAUDE_THINKING',
|
||||||
|
'CLAUDE_THINKING_BUDGET',
|
||||||
|
'CLAUDE_EFFORT',
|
||||||
|
]) {
|
||||||
|
const value =
|
||||||
|
args.envVars[key as keyof typeof args.envVars] || process.env[key];
|
||||||
|
if (value) args.env[key] = value;
|
||||||
|
}
|
||||||
|
if (args.group.agentConfig?.claudeModel) {
|
||||||
|
args.env.CLAUDE_MODEL = args.group.agentConfig.claudeModel;
|
||||||
|
}
|
||||||
|
if (args.group.agentConfig?.claudeEffort) {
|
||||||
|
args.env.CLAUDE_EFFORT = args.group.agentConfig.claudeEffort;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareCodexSessionEnvironment(args: {
|
||||||
|
env: Record<string, string>;
|
||||||
|
envVars: Record<string, string>;
|
||||||
|
projectRoot: string;
|
||||||
|
group: RegisteredGroup;
|
||||||
|
groupDir: string;
|
||||||
|
chatJid: string;
|
||||||
|
isMain: boolean;
|
||||||
|
isPairedRoom: boolean;
|
||||||
|
}): void {
|
||||||
|
const openaiKey =
|
||||||
|
args.envVars.CODEX_OPENAI_API_KEY ||
|
||||||
|
process.env.CODEX_OPENAI_API_KEY ||
|
||||||
|
args.envVars.OPENAI_API_KEY ||
|
||||||
|
process.env.OPENAI_API_KEY;
|
||||||
|
if (openaiKey) args.env.OPENAI_API_KEY = openaiKey;
|
||||||
|
|
||||||
|
const codexModel =
|
||||||
|
args.group.agentConfig?.codexModel ||
|
||||||
|
args.envVars.CODEX_MODEL ||
|
||||||
|
process.env.CODEX_MODEL;
|
||||||
|
if (codexModel) args.env.CODEX_MODEL = codexModel;
|
||||||
|
|
||||||
|
const codexEffort =
|
||||||
|
args.group.agentConfig?.codexEffort ||
|
||||||
|
args.envVars.CODEX_EFFORT ||
|
||||||
|
process.env.CODEX_EFFORT;
|
||||||
|
if (codexEffort) args.env.CODEX_EFFORT = codexEffort;
|
||||||
|
|
||||||
|
const hostCodexDir = path.join(os.homedir(), '.codex');
|
||||||
|
const sessionCodexDir = path.join(
|
||||||
|
DATA_DIR,
|
||||||
|
'sessions',
|
||||||
|
args.group.folder,
|
||||||
|
'.codex',
|
||||||
|
);
|
||||||
|
fs.mkdirSync(sessionCodexDir, { recursive: true });
|
||||||
|
|
||||||
|
const authSrc = path.join(hostCodexDir, 'auth.json');
|
||||||
|
const authDst = path.join(sessionCodexDir, 'auth.json');
|
||||||
|
if (fs.existsSync(authSrc)) fs.copyFileSync(authSrc, authDst);
|
||||||
|
for (const file of ['config.toml', 'config.json']) {
|
||||||
|
const src = path.join(hostCodexDir, file);
|
||||||
|
const dst = path.join(sessionCodexDir, file);
|
||||||
|
if (fs.existsSync(src)) {
|
||||||
|
fs.copyFileSync(src, dst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlayPath = path.join(args.groupDir, '.codex', 'config.toml');
|
||||||
|
const sessionConfigPath = path.join(sessionCodexDir, 'config.toml');
|
||||||
|
if (fs.existsSync(overlayPath)) {
|
||||||
|
const overlayToml = fs.readFileSync(overlayPath, 'utf-8').trim();
|
||||||
|
if (overlayToml) {
|
||||||
|
const baseToml = fs.existsSync(sessionConfigPath)
|
||||||
|
? fs.readFileSync(sessionConfigPath, 'utf-8').trimEnd()
|
||||||
|
: '';
|
||||||
|
fs.writeFileSync(
|
||||||
|
sessionConfigPath,
|
||||||
|
[baseToml, overlayToml].filter(Boolean).join('\n\n') + '\n',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionAgentsPath = path.join(sessionCodexDir, 'AGENTS.md');
|
||||||
|
const sessionAgents = [
|
||||||
|
readPlatformPrompt('codex', args.projectRoot),
|
||||||
|
args.isPairedRoom
|
||||||
|
? readPairedRoomPrompt('codex', args.projectRoot)
|
||||||
|
: undefined,
|
||||||
|
]
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
.join('\n\n---\n\n')
|
||||||
|
.trim();
|
||||||
|
if (sessionAgents) {
|
||||||
|
fs.writeFileSync(sessionAgentsPath, sessionAgents + '\n');
|
||||||
|
} else if (fs.existsSync(sessionAgentsPath)) {
|
||||||
|
fs.unlinkSync(sessionAgentsPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
syncDirectoryEntries(
|
||||||
|
[
|
||||||
|
path.join(os.homedir(), '.claude', 'skills'),
|
||||||
|
path.join(args.projectRoot, 'runners', 'skills'),
|
||||||
|
],
|
||||||
|
path.join(sessionCodexDir, 'skills'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const mcpServerPath = path.join(
|
||||||
|
args.projectRoot,
|
||||||
|
'runners',
|
||||||
|
'agent-runner',
|
||||||
|
'dist',
|
||||||
|
'ipc-mcp-stdio.js',
|
||||||
|
);
|
||||||
|
if (fs.existsSync(mcpServerPath)) {
|
||||||
|
let toml = fs.existsSync(sessionConfigPath)
|
||||||
|
? fs.readFileSync(sessionConfigPath, 'utf-8')
|
||||||
|
: '';
|
||||||
|
toml = toml.replace(/\n?\[mcp_servers\.nanoclaw\][\s\S]*?(?=\n\[|$)/, '');
|
||||||
|
toml = toml.replace(
|
||||||
|
/\n?\[mcp_servers\.memento-mcp\][\s\S]*?(?=\n\[|$)/,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
const mcpSection = `
|
||||||
|
[mcp_servers.nanoclaw]
|
||||||
|
command = "node"
|
||||||
|
args = [${JSON.stringify(mcpServerPath)}]
|
||||||
|
|
||||||
|
[mcp_servers.nanoclaw.env]
|
||||||
|
NANOCLAW_IPC_DIR = ${JSON.stringify(args.env.NANOCLAW_IPC_DIR)}
|
||||||
|
NANOCLAW_CHAT_JID = ${JSON.stringify(args.chatJid)}
|
||||||
|
NANOCLAW_GROUP_FOLDER = ${JSON.stringify(args.group.folder)}
|
||||||
|
NANOCLAW_IS_MAIN = ${JSON.stringify(args.isMain ? '1' : '0')}
|
||||||
|
NANOCLAW_AGENT_TYPE = ${JSON.stringify(args.env.NANOCLAW_AGENT_TYPE)}
|
||||||
|
`;
|
||||||
|
const mementoSseUrl =
|
||||||
|
args.envVars.MEMENTO_MCP_SSE_URL || process.env.MEMENTO_MCP_SSE_URL;
|
||||||
|
const mementoAccessKey =
|
||||||
|
args.envVars.MEMENTO_ACCESS_KEY || process.env.MEMENTO_ACCESS_KEY || '';
|
||||||
|
const mementoRemotePath =
|
||||||
|
args.envVars.MEMENTO_MCP_REMOTE_PATH ||
|
||||||
|
process.env.MEMENTO_MCP_REMOTE_PATH ||
|
||||||
|
'mcp-remote';
|
||||||
|
const mementoSection = mementoSseUrl
|
||||||
|
? `
|
||||||
|
[mcp_servers.memento-mcp]
|
||||||
|
command = ${JSON.stringify(mementoRemotePath)}
|
||||||
|
args = [${JSON.stringify(mementoSseUrl)}, "--header", ${JSON.stringify(`Authorization:Bearer ${mementoAccessKey}`)}]
|
||||||
|
`
|
||||||
|
: '';
|
||||||
|
fs.writeFileSync(
|
||||||
|
sessionConfigPath,
|
||||||
|
toml.trimEnd() + '\n' + mcpSection + mementoSection,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete args.env.ANTHROPIC_API_KEY;
|
||||||
|
delete args.env.ANTHROPIC_AUTH_TOKEN;
|
||||||
|
delete args.env.ANTHROPIC_BASE_URL;
|
||||||
|
delete args.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||||
|
args.env.CODEX_HOME = sessionCodexDir;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prepare the group's environment: directories, sessions, env vars.
|
* Prepare the group's environment: directories, sessions, env vars.
|
||||||
* Returns the environment variables and paths for the runner process.
|
* Returns the environment variables and paths for the runner process.
|
||||||
@@ -70,24 +342,7 @@ function prepareGroupEnvironment(
|
|||||||
'.claude',
|
'.claude',
|
||||||
);
|
);
|
||||||
fs.mkdirSync(groupSessionsDir, { recursive: true });
|
fs.mkdirSync(groupSessionsDir, { recursive: true });
|
||||||
|
ensureClaudeSessionSettings(groupSessionsDir);
|
||||||
const settingsFile = path.join(groupSessionsDir, 'settings.json');
|
|
||||||
if (!fs.existsSync(settingsFile)) {
|
|
||||||
fs.writeFileSync(
|
|
||||||
settingsFile,
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
env: {
|
|
||||||
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
|
||||||
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
|
|
||||||
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
) + '\n',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync skills into each group's .claude/ session dir
|
// Sync skills into each group's .claude/ session dir
|
||||||
// Sources: 1) user's global ~/.claude/skills 2) project workDir/.claude/skills 3) runners/skills/
|
// Sources: 1) user's global ~/.claude/skills 2) project workDir/.claude/skills 3) runners/skills/
|
||||||
@@ -99,20 +354,7 @@ function prepareGroupEnvironment(
|
|||||||
...(workDirClaude ? [path.join(workDirClaude, 'skills')] : []),
|
...(workDirClaude ? [path.join(workDirClaude, 'skills')] : []),
|
||||||
path.join(projectRoot, 'runners', 'skills'),
|
path.join(projectRoot, 'runners', 'skills'),
|
||||||
];
|
];
|
||||||
const skillsDst = path.join(groupSessionsDir, 'skills');
|
syncDirectoryEntries(skillSources, path.join(groupSessionsDir, 'skills'));
|
||||||
for (const src of skillSources) {
|
|
||||||
if (!fs.existsSync(src)) continue;
|
|
||||||
for (const entry of fs.readdirSync(src)) {
|
|
||||||
const srcPath = path.join(src, entry);
|
|
||||||
const dstPath = path.join(skillsDst, entry);
|
|
||||||
if (fs.statSync(srcPath).isDirectory()) {
|
|
||||||
fs.cpSync(srcPath, dstPath, { recursive: true });
|
|
||||||
} else {
|
|
||||||
fs.mkdirSync(skillsDst, { recursive: true });
|
|
||||||
fs.copyFileSync(srcPath, dstPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-group IPC namespace
|
// Per-group IPC namespace
|
||||||
const groupIpcDir = resolveGroupIpcPath(group.folder);
|
const groupIpcDir = resolveGroupIpcPath(group.folder);
|
||||||
@@ -172,240 +414,32 @@ function prepareGroupEnvironment(
|
|||||||
'MEMENTO_MCP_REMOTE_PATH',
|
'MEMENTO_MCP_REMOTE_PATH',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Build a clean env without Claude Code nesting detection variables
|
const env = buildBaseRunnerEnv({
|
||||||
const cleanEnv = { ...(process.env as Record<string, string>) };
|
group,
|
||||||
// Merge .env file values (readEnvFile only reads file, doesn't set process.env)
|
chatJid,
|
||||||
for (const [k, v] of Object.entries(envVars)) {
|
isMain,
|
||||||
if (v && !cleanEnv[k]) cleanEnv[k] = v;
|
groupDir,
|
||||||
}
|
groupIpcDir,
|
||||||
delete cleanEnv.CLAUDECODE;
|
globalDir,
|
||||||
delete cleanEnv.CLAUDE_CODE_ENTRYPOINT;
|
groupSessionsDir,
|
||||||
|
agentType,
|
||||||
// Ensure node and npm-global binaries (codex, etc.) are findable
|
envVars,
|
||||||
const nodeBin = path.dirname(process.execPath);
|
});
|
||||||
const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin');
|
|
||||||
const currentPath = cleanEnv.PATH || '/usr/local/bin:/usr/bin:/bin';
|
|
||||||
const extraPaths = [nodeBin, npmGlobalBin].filter(
|
|
||||||
(p) => !currentPath.includes(p) && fs.existsSync(p),
|
|
||||||
);
|
|
||||||
const enrichedPath =
|
|
||||||
extraPaths.length > 0
|
|
||||||
? `${extraPaths.join(':')}:${currentPath}`
|
|
||||||
: currentPath;
|
|
||||||
|
|
||||||
const env: Record<string, string> = {
|
|
||||||
...cleanEnv,
|
|
||||||
PATH: enrichedPath,
|
|
||||||
TZ: TIMEZONE,
|
|
||||||
HOME: os.homedir(),
|
|
||||||
// Path configuration for the runner
|
|
||||||
NANOCLAW_GROUP_DIR: groupDir,
|
|
||||||
NANOCLAW_IPC_DIR: groupIpcDir,
|
|
||||||
NANOCLAW_GLOBAL_DIR: globalDir,
|
|
||||||
// Working directory override (agent uses this as cwd instead of group dir)
|
|
||||||
...(group.workDir ? { NANOCLAW_WORK_DIR: group.workDir } : {}),
|
|
||||||
// MCP server context
|
|
||||||
NANOCLAW_CHAT_JID: chatJid,
|
|
||||||
NANOCLAW_GROUP_FOLDER: group.folder,
|
|
||||||
NANOCLAW_IS_MAIN: isMain ? '1' : '0',
|
|
||||||
NANOCLAW_AGENT_TYPE: agentType,
|
|
||||||
// Claude sessions directory — set CLAUDE_CONFIG_DIR so SDK uses per-group sessions
|
|
||||||
CLAUDE_CONFIG_DIR: groupSessionsDir,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Pass credentials directly (no proxy needed on host)
|
// Pass credentials directly (no proxy needed on host)
|
||||||
if (agentType === 'codex') {
|
if (agentType === 'codex') {
|
||||||
const openaiKey =
|
prepareCodexSessionEnvironment({
|
||||||
envVars.CODEX_OPENAI_API_KEY ||
|
env,
|
||||||
process.env.CODEX_OPENAI_API_KEY ||
|
envVars,
|
||||||
envVars.OPENAI_API_KEY ||
|
|
||||||
process.env.OPENAI_API_KEY;
|
|
||||||
if (openaiKey) env.OPENAI_API_KEY = openaiKey;
|
|
||||||
|
|
||||||
// Codex model/effort configuration (per-group overrides global)
|
|
||||||
const codexModel =
|
|
||||||
group.agentConfig?.codexModel ||
|
|
||||||
envVars.CODEX_MODEL ||
|
|
||||||
process.env.CODEX_MODEL;
|
|
||||||
if (codexModel) env.CODEX_MODEL = codexModel;
|
|
||||||
const codexEffort =
|
|
||||||
group.agentConfig?.codexEffort ||
|
|
||||||
envVars.CODEX_EFFORT ||
|
|
||||||
process.env.CODEX_EFFORT;
|
|
||||||
if (codexEffort) env.CODEX_EFFORT = codexEffort;
|
|
||||||
|
|
||||||
// Codex session directory
|
|
||||||
const hostCodexDir = path.join(os.homedir(), '.codex');
|
|
||||||
const sessionCodexDir = path.join(
|
|
||||||
DATA_DIR,
|
|
||||||
'sessions',
|
|
||||||
group.folder,
|
|
||||||
'.codex',
|
|
||||||
);
|
|
||||||
fs.mkdirSync(sessionCodexDir, { recursive: true });
|
|
||||||
const authSrc = path.join(hostCodexDir, 'auth.json');
|
|
||||||
const authDst = path.join(sessionCodexDir, 'auth.json');
|
|
||||||
if (fs.existsSync(authSrc)) fs.copyFileSync(authSrc, authDst);
|
|
||||||
for (const file of ['config.toml', 'config.json']) {
|
|
||||||
const src = path.join(hostCodexDir, file);
|
|
||||||
const dst = path.join(sessionCodexDir, file);
|
|
||||||
if (fs.existsSync(src)) {
|
|
||||||
fs.copyFileSync(src, dst);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const groupCodexConfigOverlayPath = path.join(
|
|
||||||
groupDir,
|
|
||||||
'.codex',
|
|
||||||
'config.toml',
|
|
||||||
);
|
|
||||||
const sessionCodexConfigPath = path.join(sessionCodexDir, 'config.toml');
|
|
||||||
if (fs.existsSync(groupCodexConfigOverlayPath)) {
|
|
||||||
const overlayToml = fs
|
|
||||||
.readFileSync(groupCodexConfigOverlayPath, 'utf-8')
|
|
||||||
.trim();
|
|
||||||
if (overlayToml) {
|
|
||||||
const baseToml = fs.existsSync(sessionCodexConfigPath)
|
|
||||||
? fs.readFileSync(sessionCodexConfigPath, 'utf-8').trimEnd()
|
|
||||||
: '';
|
|
||||||
const mergedToml = [baseToml, overlayToml]
|
|
||||||
.filter((value) => value.length > 0)
|
|
||||||
.join('\n\n');
|
|
||||||
fs.writeFileSync(sessionCodexConfigPath, mergedToml + '\n');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const sessionAgentsPath = path.join(sessionCodexDir, 'AGENTS.md');
|
|
||||||
const codexPlatformPrompt = readPlatformPrompt('codex', projectRoot);
|
|
||||||
const codexPairedRoomPrompt = isPairedRoom
|
|
||||||
? readPairedRoomPrompt('codex', projectRoot)
|
|
||||||
: undefined;
|
|
||||||
const sessionAgents = [codexPlatformPrompt, codexPairedRoomPrompt]
|
|
||||||
.filter((value): value is string => Boolean(value))
|
|
||||||
.join('\n\n---\n\n')
|
|
||||||
.trim();
|
|
||||||
if (sessionAgents) {
|
|
||||||
fs.writeFileSync(sessionAgentsPath, sessionAgents + '\n');
|
|
||||||
} else if (fs.existsSync(sessionAgentsPath)) {
|
|
||||||
fs.unlinkSync(sessionAgentsPath);
|
|
||||||
}
|
|
||||||
// Sync skills into Codex session dir
|
|
||||||
// SSOT: ~/.claude/skills/ (shared with Claude Code) + runners/skills/
|
|
||||||
const codexSkillSources = [
|
|
||||||
path.join(os.homedir(), '.claude', 'skills'),
|
|
||||||
path.join(projectRoot, 'runners', 'skills'),
|
|
||||||
];
|
|
||||||
const codexSkillsDst = path.join(sessionCodexDir, 'skills');
|
|
||||||
for (const src of codexSkillSources) {
|
|
||||||
if (!fs.existsSync(src)) continue;
|
|
||||||
for (const entry of fs.readdirSync(src)) {
|
|
||||||
const srcPath = path.join(src, entry);
|
|
||||||
const dstPath = path.join(codexSkillsDst, entry);
|
|
||||||
if (fs.statSync(srcPath).isDirectory()) {
|
|
||||||
fs.cpSync(srcPath, dstPath, { recursive: true });
|
|
||||||
} else {
|
|
||||||
fs.mkdirSync(codexSkillsDst, { recursive: true });
|
|
||||||
fs.copyFileSync(srcPath, dstPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inject nanoclaw MCP server into Codex config.toml
|
|
||||||
const mcpServerPath = path.join(
|
|
||||||
projectRoot,
|
projectRoot,
|
||||||
'runners',
|
group,
|
||||||
'agent-runner',
|
groupDir,
|
||||||
'dist',
|
chatJid,
|
||||||
'ipc-mcp-stdio.js',
|
isMain,
|
||||||
);
|
isPairedRoom,
|
||||||
const configTomlPath = path.join(sessionCodexDir, 'config.toml');
|
});
|
||||||
if (fs.existsSync(mcpServerPath)) {
|
|
||||||
let toml = fs.existsSync(configTomlPath)
|
|
||||||
? fs.readFileSync(configTomlPath, 'utf-8')
|
|
||||||
: '';
|
|
||||||
// Remove existing nanoclaw/memento MCP sections if present (to refresh env vars)
|
|
||||||
toml = toml.replace(/\n?\[mcp_servers\.nanoclaw\][\s\S]*?(?=\n\[|$)/, '');
|
|
||||||
toml = toml.replace(
|
|
||||||
/\n?\[mcp_servers\.memento-mcp\][\s\S]*?(?=\n\[|$)/,
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
const mcpSection = `
|
|
||||||
[mcp_servers.nanoclaw]
|
|
||||||
command = "node"
|
|
||||||
args = [${JSON.stringify(mcpServerPath)}]
|
|
||||||
|
|
||||||
[mcp_servers.nanoclaw.env]
|
|
||||||
NANOCLAW_IPC_DIR = ${JSON.stringify(env.NANOCLAW_IPC_DIR)}
|
|
||||||
NANOCLAW_CHAT_JID = ${JSON.stringify(chatJid)}
|
|
||||||
NANOCLAW_GROUP_FOLDER = ${JSON.stringify(group.folder)}
|
|
||||||
NANOCLAW_IS_MAIN = ${JSON.stringify(isMain ? '1' : '0')}
|
|
||||||
NANOCLAW_AGENT_TYPE = ${JSON.stringify(env.NANOCLAW_AGENT_TYPE)}
|
|
||||||
`;
|
|
||||||
// Inject memento-mcp if MEMENTO_MCP_SSE_URL is set
|
|
||||||
const mementoSseUrl =
|
|
||||||
envVars.MEMENTO_MCP_SSE_URL || process.env.MEMENTO_MCP_SSE_URL;
|
|
||||||
const mementoAccessKey =
|
|
||||||
envVars.MEMENTO_ACCESS_KEY || process.env.MEMENTO_ACCESS_KEY || '';
|
|
||||||
const mementoRemotePath =
|
|
||||||
envVars.MEMENTO_MCP_REMOTE_PATH ||
|
|
||||||
process.env.MEMENTO_MCP_REMOTE_PATH ||
|
|
||||||
'mcp-remote';
|
|
||||||
const mementoSection = mementoSseUrl
|
|
||||||
? `
|
|
||||||
[mcp_servers.memento-mcp]
|
|
||||||
command = ${JSON.stringify(mementoRemotePath)}
|
|
||||||
args = [${JSON.stringify(mementoSseUrl)}, "--header", ${JSON.stringify(`Authorization:Bearer ${mementoAccessKey}`)}]
|
|
||||||
`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
toml = toml.trimEnd() + '\n' + mcpSection + mementoSection;
|
|
||||||
fs.writeFileSync(configTomlPath, toml);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanitize secrets: prevent API keys from leaking to codex subprocesses
|
|
||||||
delete env.ANTHROPIC_API_KEY;
|
|
||||||
delete env.ANTHROPIC_AUTH_TOKEN;
|
|
||||||
delete env.ANTHROPIC_BASE_URL;
|
|
||||||
delete env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
||||||
|
|
||||||
env.CODEX_HOME = sessionCodexDir;
|
|
||||||
} else {
|
} else {
|
||||||
// Claude Code — pass real credentials directly
|
prepareClaudeEnvironment({ env, envVars, group });
|
||||||
if (envVars.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY) {
|
|
||||||
env.ANTHROPIC_API_KEY =
|
|
||||||
envVars.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY || '';
|
|
||||||
}
|
|
||||||
if (envVars.ANTHROPIC_AUTH_TOKEN || process.env.ANTHROPIC_AUTH_TOKEN) {
|
|
||||||
env.ANTHROPIC_AUTH_TOKEN =
|
|
||||||
envVars.ANTHROPIC_AUTH_TOKEN || process.env.ANTHROPIC_AUTH_TOKEN || '';
|
|
||||||
}
|
|
||||||
if (envVars.ANTHROPIC_BASE_URL || process.env.ANTHROPIC_BASE_URL) {
|
|
||||||
env.ANTHROPIC_BASE_URL =
|
|
||||||
envVars.ANTHROPIC_BASE_URL || process.env.ANTHROPIC_BASE_URL || '';
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
envVars.CLAUDE_CODE_OAUTH_TOKEN ||
|
|
||||||
process.env.CLAUDE_CODE_OAUTH_TOKEN
|
|
||||||
) {
|
|
||||||
env.CLAUDE_CODE_OAUTH_TOKEN =
|
|
||||||
envVars.CLAUDE_CODE_OAUTH_TOKEN ||
|
|
||||||
process.env.CLAUDE_CODE_OAUTH_TOKEN ||
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
// Model/thinking config (per-group overrides global)
|
|
||||||
for (const key of [
|
|
||||||
'CLAUDE_MODEL',
|
|
||||||
'CLAUDE_THINKING',
|
|
||||||
'CLAUDE_THINKING_BUDGET',
|
|
||||||
'CLAUDE_EFFORT',
|
|
||||||
]) {
|
|
||||||
const val = envVars[key as keyof typeof envVars] || process.env[key];
|
|
||||||
if (val) env[key] = val;
|
|
||||||
}
|
|
||||||
if (group.agentConfig?.claudeModel) {
|
|
||||||
env.CLAUDE_MODEL = group.agentConfig.claudeModel;
|
|
||||||
}
|
|
||||||
if (group.agentConfig?.claudeEffort) {
|
|
||||||
env.CLAUDE_EFFORT = group.agentConfig.claudeEffort;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { env, groupDir, runnerDir };
|
return { env, groupDir, runnerDir };
|
||||||
|
|||||||
539
src/dashboard.ts
539
src/dashboard.ts
@@ -1,23 +1,13 @@
|
|||||||
import { ChildProcess, execSync, spawn } from 'child_process';
|
import { STATUS_SHOW_ROOMS } from './config.js';
|
||||||
import fs from 'fs';
|
|
||||||
import os from 'os';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
import {
|
|
||||||
fetchClaudeUsageViaCli,
|
|
||||||
type ClaudeUsageData,
|
|
||||||
} from './claude-usage.js';
|
|
||||||
import { STATUS_SHOW_ROOMS, USAGE_DASHBOARD_ENABLED } from './config.js';
|
|
||||||
import {
|
import {
|
||||||
composeDashboardContent,
|
composeDashboardContent,
|
||||||
type DashboardRoomLine,
|
type DashboardRoomLine,
|
||||||
getStatusLabel as formatDashboardStatusLabel,
|
getStatusLabel,
|
||||||
renderCategorizedRoomSections,
|
renderCategorizedRoomSections,
|
||||||
} from './dashboard-render.js';
|
} from './dashboard-render.js';
|
||||||
import { readEnvFile } from './env.js';
|
import type { GroupQueue } from './group-queue.js';
|
||||||
import { GroupQueue, GroupStatus } from './group-queue.js';
|
import { startUnifiedDashboard } from './unified-dashboard.js';
|
||||||
import { logger } from './logger.js';
|
import type { AgentType, Channel, RegisteredGroup } from './types.js';
|
||||||
import { Channel, ChannelMeta, RegisteredGroup } from './types.js';
|
|
||||||
|
|
||||||
export interface DashboardOptions {
|
export interface DashboardOptions {
|
||||||
assistantName: string;
|
assistantName: string;
|
||||||
@@ -28,88 +18,7 @@ export interface DashboardOptions {
|
|||||||
statusChannelId: string;
|
statusChannelId: string;
|
||||||
statusUpdateInterval: number;
|
statusUpdateInterval: number;
|
||||||
usageUpdateInterval: number;
|
usageUpdateInterval: number;
|
||||||
}
|
serviceAgentType?: AgentType;
|
||||||
|
|
||||||
interface CodexRateLimit {
|
|
||||||
limitId?: string;
|
|
||||||
limitName: string | null;
|
|
||||||
primary: { usedPercent: number; resetsAt: string | number };
|
|
||||||
secondary: { usedPercent: number; resetsAt: string | number };
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_ICONS: Record<string, string> = {
|
|
||||||
processing: '🟡',
|
|
||||||
waiting: '🔵',
|
|
||||||
inactive: '⚪',
|
|
||||||
};
|
|
||||||
|
|
||||||
const CHANNEL_META_REFRESH_MS = 300000;
|
|
||||||
|
|
||||||
let statusMessageId: string | null = null;
|
|
||||||
let usageMessageId: string | null = null;
|
|
||||||
let usageUpdateInProgress = false;
|
|
||||||
let channelMetaCache = new Map<string, ChannelMeta>();
|
|
||||||
let channelMetaLastRefresh = 0;
|
|
||||||
|
|
||||||
function findDiscordChannel(channels: Channel[]): Channel | undefined {
|
|
||||||
return channels.find((c) => c.name.startsWith('discord') && c.isConnected());
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatElapsed(ms: number): string {
|
|
||||||
const s = Math.floor(ms / 1000);
|
|
||||||
if (s < 60) return `${s}s`;
|
|
||||||
if (s < 3600) {
|
|
||||||
const m = Math.floor(s / 60);
|
|
||||||
const rem = s % 60;
|
|
||||||
return `${m}m${rem.toString().padStart(2, '0')}s`;
|
|
||||||
}
|
|
||||||
const h = Math.floor(s / 3600);
|
|
||||||
const m = Math.floor((s % 3600) / 60);
|
|
||||||
if (h < 24) return `${h}h${m.toString().padStart(2, '0')}m`;
|
|
||||||
const d = Math.floor(h / 24);
|
|
||||||
const remH = h % 24;
|
|
||||||
return `${d}d${remH}h`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatResetKST(value: string | number): string {
|
|
||||||
try {
|
|
||||||
const date =
|
|
||||||
typeof value === 'number' ? new Date(value * 1000) : new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) return String(value);
|
|
||||||
return date.toLocaleString('ko-KR', {
|
|
||||||
timeZone: 'Asia/Seoul',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshChannelMeta(opts: DashboardOptions): Promise<void> {
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - channelMetaLastRefresh < CHANNEL_META_REFRESH_MS) return;
|
|
||||||
|
|
||||||
const ch = opts.channels.find(
|
|
||||||
(c) => c.name.startsWith('discord') && c.isConnected() && c.getChannelMeta,
|
|
||||||
);
|
|
||||||
if (!ch?.getChannelMeta) return;
|
|
||||||
|
|
||||||
const jids = Object.keys(opts.registeredGroups()).filter((j) =>
|
|
||||||
j.startsWith('dc:'),
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
channelMetaCache = await ch.getChannelMeta(jids);
|
|
||||||
channelMetaLastRefresh = now;
|
|
||||||
} catch (err) {
|
|
||||||
logger.debug({ err }, 'Failed to refresh channel metadata');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusLabel(status: GroupStatus): string {
|
|
||||||
return formatDashboardStatusLabel(status);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSessionLabel(sessionId: string | undefined): string {
|
function getSessionLabel(sessionId: string | undefined): string {
|
||||||
@@ -118,335 +27,36 @@ function getSessionLabel(sessionId: string | undefined): string {
|
|||||||
return `세션 ${shortId}`;
|
return `세션 ${shortId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal - exported for testing */
|
|
||||||
export function buildStatusContent(opts: DashboardOptions): string {
|
export function buildStatusContent(opts: DashboardOptions): string {
|
||||||
if (!STATUS_SHOW_ROOMS) return '';
|
if (!STATUS_SHOW_ROOMS) return '';
|
||||||
|
|
||||||
const registeredGroups = opts.registeredGroups();
|
|
||||||
const sessions = opts.getSessions();
|
const sessions = opts.getSessions();
|
||||||
const jids = Object.keys(registeredGroups);
|
const groups = opts.registeredGroups();
|
||||||
const statuses = opts.queue.getStatuses(jids);
|
const statuses = opts.queue.getStatuses(Object.keys(groups));
|
||||||
|
|
||||||
const entries = statuses
|
|
||||||
.map((status) => ({
|
|
||||||
status,
|
|
||||||
group: registeredGroups[status.jid],
|
|
||||||
meta: channelMetaCache.get(status.jid),
|
|
||||||
}))
|
|
||||||
.filter((entry) => entry.group);
|
|
||||||
|
|
||||||
const categoryMap = new Map<string, typeof entries>();
|
|
||||||
for (const entry of entries) {
|
|
||||||
const category = entry.meta?.category || '기타';
|
|
||||||
if (!categoryMap.has(category)) categoryMap.set(category, []);
|
|
||||||
categoryMap.get(category)!.push(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedCategories = [...categoryMap.entries()].sort((a, b) => {
|
|
||||||
const posA = a[1][0]?.meta?.categoryPosition ?? 999;
|
|
||||||
const posB = b[1][0]?.meta?.categoryPosition ?? 999;
|
|
||||||
return posA - posB;
|
|
||||||
});
|
|
||||||
|
|
||||||
const roomLines: DashboardRoomLine[] = [];
|
|
||||||
let totalActive = 0;
|
let totalActive = 0;
|
||||||
let totalWaiting = 0;
|
let totalWaiting = 0;
|
||||||
let total = 0;
|
const roomLines: DashboardRoomLine[] = statuses
|
||||||
|
.map((status) => {
|
||||||
|
const group = groups[status.jid];
|
||||||
|
if (!group) return null;
|
||||||
|
if (status.status === 'processing') totalActive += 1;
|
||||||
|
if (status.status === 'waiting') totalWaiting += 1;
|
||||||
|
return {
|
||||||
|
category: '기타',
|
||||||
|
categoryPosition: 999,
|
||||||
|
position: 999,
|
||||||
|
line: ` **${group.name}** — ${getStatusLabel(status)} · ${getSessionLabel(sessions[group.folder])}`,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((line): line is DashboardRoomLine => Boolean(line));
|
||||||
|
|
||||||
for (const [categoryName, categoryEntries] of sortedCategories) {
|
return composeDashboardContent([
|
||||||
categoryEntries.sort(
|
`**에이전트 상태** (${opts.assistantName}) — 활성 ${totalActive} | 큐대기 ${totalWaiting} | 전체 ${roomLines.length}\n\n${renderCategorizedRoomSections({
|
||||||
(a, b) => (a.meta?.position ?? 999) - (b.meta?.position ?? 999),
|
lines: roomLines,
|
||||||
);
|
showCategoryHeaders: false,
|
||||||
|
})}`,
|
||||||
categoryEntries.forEach((entry) => {
|
|
||||||
const icon = STATUS_ICONS[entry.status.status] || '⚪';
|
|
||||||
const label = getStatusLabel(entry.status);
|
|
||||||
const sessionLabel = getSessionLabel(sessions[entry.group.folder]);
|
|
||||||
const name = entry.meta?.name ? `#${entry.meta.name}` : entry.group.name;
|
|
||||||
roomLines.push({
|
|
||||||
category: categoryName,
|
|
||||||
categoryPosition: entry.meta?.categoryPosition ?? 999,
|
|
||||||
position: entry.meta?.position ?? 999,
|
|
||||||
line: ` ${icon} **${name}** — ${label} · ${sessionLabel}`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
totalActive += categoryEntries.filter(
|
|
||||||
(entry) => entry.status.status === 'processing',
|
|
||||||
).length;
|
|
||||||
totalWaiting += categoryEntries.filter(
|
|
||||||
(entry) => entry.status.status === 'waiting',
|
|
||||||
).length;
|
|
||||||
total += categoryEntries.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
const header = `**에이전트 상태** (${opts.assistantName}) — 활성 ${totalActive} | 큐대기 ${totalWaiting} | 전체 ${total}`;
|
|
||||||
return composeDashboardContent(
|
|
||||||
[
|
|
||||||
`${header}\n\n${renderCategorizedRoomSections({
|
|
||||||
lines: roomLines,
|
|
||||||
showCategoryHeaders: channelMetaCache.size > 0,
|
|
||||||
})}`,
|
|
||||||
],
|
|
||||||
new Date(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchClaudeUsage(): Promise<ClaudeUsageData | null> {
|
|
||||||
const cliUsage = await fetchClaudeUsageViaCli();
|
|
||||||
if (cliUsage) 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) return null;
|
|
||||||
return (await res.json()) as ClaudeUsageData;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchCodexUsage(): Promise<CodexRateLimit[] | null> {
|
|
||||||
const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin', 'codex');
|
|
||||||
const codexBin = fs.existsSync(npmGlobalBin) ? npmGlobalBin : 'codex';
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
let done = false;
|
|
||||||
const finish = (value: CodexRateLimit[] | null) => {
|
|
||||||
if (done) return;
|
|
||||||
done = true;
|
|
||||||
clearTimeout(timer);
|
|
||||||
if (proc) {
|
|
||||||
try {
|
|
||||||
proc.kill();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resolve(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const timer = setTimeout(() => finish(null), 20000);
|
|
||||||
|
|
||||||
let proc: ChildProcess | null = null;
|
|
||||||
try {
|
|
||||||
proc = spawn(codexBin, ['app-server'], {
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
env: {
|
|
||||||
...(process.env as Record<string, string>),
|
|
||||||
PATH: [
|
|
||||||
path.dirname(process.execPath),
|
|
||||||
path.join(os.homedir(), '.npm-global', 'bin'),
|
|
||||||
process.env.PATH || '',
|
|
||||||
].join(':'),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
resolve(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!proc.stdout || !proc.stdin) {
|
|
||||||
finish(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stdout = proc.stdout;
|
|
||||||
const stdin = proc.stdin;
|
|
||||||
|
|
||||||
proc.on('error', () => finish(null));
|
|
||||||
proc.on('close', () => finish(null));
|
|
||||||
|
|
||||||
let buf = '';
|
|
||||||
stdout.on('data', (chunk: Buffer) => {
|
|
||||||
buf += chunk.toString();
|
|
||||||
const lines = buf.split('\n');
|
|
||||||
buf = lines.pop() || '';
|
|
||||||
for (const line of lines) {
|
|
||||||
if (!line.trim()) continue;
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(line);
|
|
||||||
if (msg.id === 1) {
|
|
||||||
stdin.write(
|
|
||||||
JSON.stringify({
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
id: 2,
|
|
||||||
method: 'account/rateLimits/read',
|
|
||||||
params: {},
|
|
||||||
}) + '\n',
|
|
||||||
);
|
|
||||||
} else if (msg.id === 2 && msg.result) {
|
|
||||||
const byId = msg.result.rateLimitsByLimitId;
|
|
||||||
if (byId && typeof byId === 'object') {
|
|
||||||
finish(Object.values(byId) as CodexRateLimit[]);
|
|
||||||
} else {
|
|
||||||
finish(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* non-JSON line */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
stdin.write(
|
|
||||||
JSON.stringify({
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
id: 1,
|
|
||||||
method: 'initialize',
|
|
||||||
params: { clientInfo: { name: 'usage-monitor', version: '1.0' } },
|
|
||||||
}) + '\n',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildUsageContent(): Promise<string> {
|
|
||||||
const lines: string[] = [];
|
|
||||||
const [claudeUsage, codexUsage] = await Promise.all([
|
|
||||||
fetchClaudeUsage(),
|
|
||||||
fetchCodexUsage(),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const bar = (pct: number) => {
|
|
||||||
const filled = Math.round(pct / 10);
|
|
||||||
return '█'.repeat(filled) + '░'.repeat(10 - filled);
|
|
||||||
};
|
|
||||||
|
|
||||||
lines.push('📊 *사용량*');
|
|
||||||
|
|
||||||
type UsageRow = {
|
|
||||||
name: string;
|
|
||||||
h5pct: number;
|
|
||||||
h5reset: string;
|
|
||||||
d7pct: number;
|
|
||||||
d7reset: string;
|
|
||||||
};
|
|
||||||
const rows: UsageRow[] = [];
|
|
||||||
|
|
||||||
if (claudeUsage) {
|
|
||||||
const h5 = claudeUsage.five_hour;
|
|
||||||
const d7 = claudeUsage.seven_day;
|
|
||||||
rows.push({
|
|
||||||
name: 'Claude',
|
|
||||||
h5pct: h5
|
|
||||||
? h5.utilization > 1
|
|
||||||
? Math.round(h5.utilization)
|
|
||||||
: Math.round(h5.utilization * 100)
|
|
||||||
: -1,
|
|
||||||
h5reset: h5 ? formatResetKST(h5.resets_at) : '',
|
|
||||||
d7pct: d7
|
|
||||||
? d7.utilization > 1
|
|
||||||
? Math.round(d7.utilization)
|
|
||||||
: Math.round(d7.utilization * 100)
|
|
||||||
: -1,
|
|
||||||
d7reset: d7 ? formatResetKST(d7.resets_at) : '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (codexUsage && Array.isArray(codexUsage)) {
|
|
||||||
const relevant = codexUsage.filter(
|
|
||||||
(limit) =>
|
|
||||||
limit.primary.usedPercent > 0 || limit.secondary.usedPercent > 0,
|
|
||||||
);
|
|
||||||
const display = relevant.length > 0 ? relevant : codexUsage.slice(0, 1);
|
|
||||||
for (const limit of display) {
|
|
||||||
rows.push({
|
|
||||||
name: 'Codex',
|
|
||||||
h5pct: Math.round(limit.primary.usedPercent),
|
|
||||||
h5reset: formatResetKST(limit.primary.resetsAt),
|
|
||||||
d7pct: Math.round(limit.secondary.usedPercent),
|
|
||||||
d7reset: formatResetKST(limit.secondary.resetsAt),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
|
||||||
lines.push('```');
|
|
||||||
lines.push(' 5-Hour 7-Day');
|
|
||||||
for (const row of rows) {
|
|
||||||
const h5 =
|
|
||||||
row.h5pct >= 0
|
|
||||||
? `${bar(row.h5pct)} ${String(row.h5pct).padStart(3)}%`
|
|
||||||
: ' — ';
|
|
||||||
const d7 =
|
|
||||||
row.d7pct >= 0
|
|
||||||
? `${bar(row.d7pct)} ${String(row.d7pct).padStart(3)}%`
|
|
||||||
: ' — ';
|
|
||||||
lines.push(`${row.name.padEnd(8)}${h5} ${d7}`);
|
|
||||||
}
|
|
||||||
lines.push('```');
|
|
||||||
} else {
|
|
||||||
lines.push('_조회 불가_');
|
|
||||||
}
|
|
||||||
lines.push('');
|
|
||||||
|
|
||||||
lines.push('🖥️ *서버*');
|
|
||||||
|
|
||||||
const loadAvg = os.loadavg();
|
|
||||||
const cpuCount = os.cpus().length;
|
|
||||||
const cpuPct = Math.round((loadAvg[1] / cpuCount) * 100);
|
|
||||||
|
|
||||||
const totalMem = os.totalmem();
|
|
||||||
const usedMem = totalMem - os.freemem();
|
|
||||||
const memPct = Math.round((usedMem / totalMem) * 100);
|
|
||||||
const memUsedGB = (usedMem / 1073741824).toFixed(1);
|
|
||||||
const memTotalGB = (totalMem / 1073741824).toFixed(1);
|
|
||||||
|
|
||||||
let diskPct = 0;
|
|
||||||
let diskUsedGB = '?';
|
|
||||||
let diskTotalGB = '?';
|
|
||||||
try {
|
|
||||||
const df = execSync('df -B1 / | tail -1', {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
timeout: 5000,
|
|
||||||
}).trim();
|
|
||||||
const parts = df.split(/\s+/);
|
|
||||||
const diskUsed = parseInt(parts[2], 10);
|
|
||||||
const diskTotal = parseInt(parts[1], 10);
|
|
||||||
diskPct = Math.round((diskUsed / diskTotal) * 100);
|
|
||||||
diskUsedGB = (diskUsed / 1073741824).toFixed(0);
|
|
||||||
diskTotalGB = (diskTotal / 1073741824).toFixed(0);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push('```');
|
|
||||||
lines.push(`${'CPU'.padEnd(8)}${bar(cpuPct)} ${String(cpuPct).padStart(3)}%`);
|
|
||||||
lines.push(
|
|
||||||
`${'Memory'.padEnd(8)}${bar(memPct)} ${String(memPct).padStart(3)}% ${memUsedGB}/${memTotalGB}GB`,
|
|
||||||
);
|
|
||||||
lines.push(
|
|
||||||
`${'Disk'.padEnd(8)}${bar(diskPct)} ${String(diskPct).padStart(3)}% ${diskUsedGB}/${diskTotalGB}GB`,
|
|
||||||
);
|
|
||||||
lines.push(`${'Uptime'.padEnd(8)}${formatElapsed(os.uptime() * 1000)}`);
|
|
||||||
lines.push('```');
|
|
||||||
|
|
||||||
return (
|
|
||||||
lines.join('\n') +
|
|
||||||
`\n_${new Date().toLocaleTimeString('ko-KR', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})}_`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function purgeDashboardChannel(
|
export async function purgeDashboardChannel(
|
||||||
@@ -455,89 +65,32 @@ export async function purgeDashboardChannel(
|
|||||||
if (!opts.statusChannelId) return;
|
if (!opts.statusChannelId) return;
|
||||||
|
|
||||||
const statusJid = `dc:${opts.statusChannelId}`;
|
const statusJid = `dc:${opts.statusChannelId}`;
|
||||||
const ch = opts.channels.find(
|
const channel = opts.channels.find(
|
||||||
(channel) =>
|
(item) =>
|
||||||
channel.name.startsWith('discord') &&
|
item.name.startsWith('discord') &&
|
||||||
channel.isConnected() &&
|
item.isConnected() &&
|
||||||
channel.purgeChannel,
|
item.purgeChannel,
|
||||||
);
|
);
|
||||||
if (ch?.purgeChannel) {
|
if (channel?.purgeChannel) {
|
||||||
await ch.purgeChannel(statusJid);
|
await channel.purgeChannel(statusJid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startStatusDashboard(
|
export async function startStatusDashboard(
|
||||||
opts: DashboardOptions,
|
opts: DashboardOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!opts.statusChannelId) return;
|
await startUnifiedDashboard({
|
||||||
|
assistantName: opts.assistantName,
|
||||||
const statusJid = `dc:${opts.statusChannelId}`;
|
serviceAgentType: opts.serviceAgentType || 'claude-code',
|
||||||
|
statusChannelId: opts.statusChannelId,
|
||||||
const updateStatus = async () => {
|
statusUpdateInterval: opts.statusUpdateInterval,
|
||||||
const ch = findDiscordChannel(opts.channels);
|
usageUpdateInterval: opts.usageUpdateInterval,
|
||||||
if (!ch) return;
|
channels: opts.channels,
|
||||||
|
queue: opts.queue,
|
||||||
try {
|
registeredGroups: opts.registeredGroups,
|
||||||
await refreshChannelMeta(opts);
|
});
|
||||||
const content = buildStatusContent(opts);
|
|
||||||
if (!content) {
|
|
||||||
statusMessageId = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statusMessageId && ch.editMessage) {
|
|
||||||
await ch.editMessage(statusJid, statusMessageId, content);
|
|
||||||
} else if (ch.sendAndTrack) {
|
|
||||||
const id = await ch.sendAndTrack(statusJid, content);
|
|
||||||
if (id) statusMessageId = id;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.debug({ err }, 'Status dashboard update failed');
|
|
||||||
statusMessageId = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
setInterval(updateStatus, opts.statusUpdateInterval);
|
|
||||||
await updateStatus();
|
|
||||||
logger.info({ channelId: opts.statusChannelId }, 'Status dashboard started');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startUsageDashboard(
|
export async function startUsageDashboard(): Promise<void> {
|
||||||
opts: DashboardOptions,
|
// Usage dashboard is integrated into startStatusDashboard via unified dashboard.
|
||||||
): Promise<void> {
|
|
||||||
if (!opts.statusChannelId) return;
|
|
||||||
if (!USAGE_DASHBOARD_ENABLED) return;
|
|
||||||
|
|
||||||
const statusJid = `dc:${opts.statusChannelId}`;
|
|
||||||
|
|
||||||
const updateUsage = async () => {
|
|
||||||
if (usageUpdateInProgress) return;
|
|
||||||
usageUpdateInProgress = true;
|
|
||||||
|
|
||||||
const ch = findDiscordChannel(opts.channels);
|
|
||||||
if (!ch) {
|
|
||||||
usageUpdateInProgress = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const content = await buildUsageContent();
|
|
||||||
|
|
||||||
if (usageMessageId && ch.editMessage) {
|
|
||||||
await ch.editMessage(statusJid, usageMessageId, content);
|
|
||||||
} else if (ch.sendAndTrack) {
|
|
||||||
const id = await ch.sendAndTrack(statusJid, content);
|
|
||||||
if (id) usageMessageId = id;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.debug({ err }, 'Usage dashboard update failed');
|
|
||||||
usageMessageId = null;
|
|
||||||
} finally {
|
|
||||||
usageUpdateInProgress = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
setInterval(updateUsage, opts.usageUpdateInterval);
|
|
||||||
await updateUsage();
|
|
||||||
logger.info('Usage dashboard started');
|
|
||||||
}
|
}
|
||||||
|
|||||||
766
src/index.ts
766
src/index.ts
@@ -1,6 +1,4 @@
|
|||||||
import { ChildProcess, execSync, spawn } from 'child_process';
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import os from 'os';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -11,11 +9,9 @@ import {
|
|||||||
SERVICE_AGENT_TYPE,
|
SERVICE_AGENT_TYPE,
|
||||||
isSessionCommandSenderAllowed,
|
isSessionCommandSenderAllowed,
|
||||||
STATUS_CHANNEL_ID,
|
STATUS_CHANNEL_ID,
|
||||||
STATUS_SHOW_ROOMS,
|
|
||||||
STATUS_UPDATE_INTERVAL,
|
STATUS_UPDATE_INTERVAL,
|
||||||
TIMEZONE,
|
TIMEZONE,
|
||||||
TRIGGER_PATTERN,
|
TRIGGER_PATTERN,
|
||||||
USAGE_DASHBOARD_ENABLED,
|
|
||||||
USAGE_UPDATE_INTERVAL,
|
USAGE_UPDATE_INTERVAL,
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
import './channels/index.js';
|
import './channels/index.js';
|
||||||
@@ -26,7 +22,6 @@ import {
|
|||||||
import { writeGroupsSnapshot } from './agent-runner.js';
|
import { writeGroupsSnapshot } from './agent-runner.js';
|
||||||
import { listAvailableGroups } from './available-groups.js';
|
import { listAvailableGroups } from './available-groups.js';
|
||||||
import {
|
import {
|
||||||
getAllChats,
|
|
||||||
getAllRegisteredGroups,
|
getAllRegisteredGroups,
|
||||||
getAllSessions,
|
getAllSessions,
|
||||||
getAllTasks,
|
getAllTasks,
|
||||||
@@ -37,20 +32,13 @@ import {
|
|||||||
initDatabase,
|
initDatabase,
|
||||||
setRegisteredGroup,
|
setRegisteredGroup,
|
||||||
setRouterState,
|
setRouterState,
|
||||||
updateRegisteredGroupName,
|
|
||||||
deleteSession,
|
deleteSession,
|
||||||
setSession,
|
setSession,
|
||||||
storeChatMetadata,
|
storeChatMetadata,
|
||||||
storeMessage,
|
storeMessage,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import {
|
import { composeDashboardContent } from './dashboard-render.js';
|
||||||
composeDashboardContent,
|
|
||||||
formatElapsed,
|
|
||||||
getStatusLabel as formatDashboardStatusLabel,
|
|
||||||
renderCategorizedRoomSections,
|
|
||||||
} from './dashboard-render.js';
|
|
||||||
import { GroupQueue } from './group-queue.js';
|
import { GroupQueue } from './group-queue.js';
|
||||||
import { readEnvFile } from './env.js';
|
|
||||||
import { resolveGroupFolderPath } from './group-folder.js';
|
import { resolveGroupFolderPath } from './group-folder.js';
|
||||||
import { startIpcWatcher } from './ipc.js';
|
import { startIpcWatcher } from './ipc.js';
|
||||||
import { findChannel, formatOutbound } from './router.js';
|
import { findChannel, formatOutbound } from './router.js';
|
||||||
@@ -70,12 +58,9 @@ import {
|
|||||||
shouldDropMessage,
|
shouldDropMessage,
|
||||||
} from './sender-allowlist.js';
|
} from './sender-allowlist.js';
|
||||||
import { createMessageRuntime } from './message-runtime.js';
|
import { createMessageRuntime } from './message-runtime.js';
|
||||||
import {
|
|
||||||
readStatusSnapshots,
|
|
||||||
writeStatusSnapshot,
|
|
||||||
} from './status-dashboard.js';
|
|
||||||
import { startSchedulerLoop } from './task-scheduler.js';
|
import { startSchedulerLoop } from './task-scheduler.js';
|
||||||
import { Channel, ChannelMeta, NewMessage, RegisteredGroup } from './types.js';
|
import { startUnifiedDashboard } from './unified-dashboard.js';
|
||||||
|
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { normalizeStoredSeqCursor } from './message-cursor.js';
|
import { normalizeStoredSeqCursor } from './message-cursor.js';
|
||||||
|
|
||||||
@@ -240,722 +225,6 @@ export function _setRegisteredGroups(
|
|||||||
registeredGroups = groups;
|
registeredGroups = groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Status & Usage Dashboards ───────────────────────────────────
|
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
|
||||||
if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)}GB`;
|
|
||||||
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(0)}MB`;
|
|
||||||
return `${(bytes / 1024).toFixed(0)}KB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function usageEmoji(pct: number): string {
|
|
||||||
if (pct >= 80) return '🔴';
|
|
||||||
if (pct >= 50) return '🟡';
|
|
||||||
return '🟢';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatResetKST(value: string | number): string {
|
|
||||||
try {
|
|
||||||
// Handle unix timestamp (seconds) or ISO string
|
|
||||||
const date =
|
|
||||||
typeof value === 'number' ? new Date(value * 1000) : new Date(value);
|
|
||||||
return date.toLocaleString('ko-KR', {
|
|
||||||
timeZone: 'Asia/Seoul',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_ICONS: Record<string, string> = {
|
|
||||||
processing: '🟡',
|
|
||||||
idle: '🟢',
|
|
||||||
waiting: '🔵',
|
|
||||||
inactive: '⚪',
|
|
||||||
};
|
|
||||||
|
|
||||||
let statusMessageId: string | null = null;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- kept for compat
|
|
||||||
let usageMessageId: string | null = null;
|
|
||||||
let cachedUsageContent: string = '';
|
|
||||||
let cachedClaudeUsageData: ClaudeUsageData | null = null;
|
|
||||||
|
|
||||||
// Cache for Discord channel metadata (name, position, category)
|
|
||||||
let channelMetaCache = new Map<string, ChannelMeta>();
|
|
||||||
let channelMetaLastRefresh = 0;
|
|
||||||
const CHANNEL_META_REFRESH_MS = 300000; // 5 minutes
|
|
||||||
const STATUS_SNAPSHOT_MAX_AGE_MS = 60000;
|
|
||||||
|
|
||||||
async function refreshChannelMeta(): Promise<void> {
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - channelMetaLastRefresh < CHANNEL_META_REFRESH_MS) return;
|
|
||||||
|
|
||||||
const ch = channels.find(
|
|
||||||
(c) => c.name.startsWith('discord') && c.isConnected() && c.getChannelMeta,
|
|
||||||
);
|
|
||||||
if (!ch?.getChannelMeta) return;
|
|
||||||
|
|
||||||
// Include jids from both local registeredGroups and other service snapshots
|
|
||||||
const localJids = Object.keys(registeredGroups).filter((j) =>
|
|
||||||
j.startsWith('dc:'),
|
|
||||||
);
|
|
||||||
const snapshotJids = readStatusSnapshots(STATUS_SNAPSHOT_MAX_AGE_MS)
|
|
||||||
.flatMap((s) => s.entries.map((e) => e.jid))
|
|
||||||
.filter((j) => j.startsWith('dc:'));
|
|
||||||
const jids = [...new Set([...localJids, ...snapshotJids])];
|
|
||||||
try {
|
|
||||||
channelMetaCache = await ch.getChannelMeta(jids);
|
|
||||||
channelMetaLastRefresh = now;
|
|
||||||
|
|
||||||
// Auto-sync DB group names with Discord channel names
|
|
||||||
for (const [jid, meta] of channelMetaCache) {
|
|
||||||
if (!meta.name) continue;
|
|
||||||
const group = registeredGroups[jid];
|
|
||||||
if (group && group.name !== meta.name) {
|
|
||||||
logger.info(
|
|
||||||
{ jid, oldName: group.name, newName: meta.name },
|
|
||||||
'Syncing group name to Discord channel name',
|
|
||||||
);
|
|
||||||
group.name = meta.name;
|
|
||||||
updateRegisteredGroupName(jid, meta.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.debug({ err }, 'Failed to refresh channel metadata');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusLabel(s: {
|
|
||||||
status: 'processing' | 'waiting' | 'inactive';
|
|
||||||
elapsedMs: number | null;
|
|
||||||
pendingMessages: boolean;
|
|
||||||
pendingTasks: number;
|
|
||||||
}): string {
|
|
||||||
return formatDashboardStatusLabel({
|
|
||||||
status: s.status,
|
|
||||||
elapsedMs: s.elapsedMs,
|
|
||||||
pendingTasks: s.pendingTasks,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAgentDisplayName(agentType: 'claude-code' | 'codex'): string {
|
|
||||||
return agentType === 'codex' ? '코덱스' : '클코';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRoomName(
|
|
||||||
jid: string,
|
|
||||||
meta: ChannelMeta | undefined,
|
|
||||||
fallbackName: string | undefined,
|
|
||||||
chatName: string | undefined,
|
|
||||||
): string {
|
|
||||||
const base =
|
|
||||||
meta?.name ||
|
|
||||||
(chatName && chatName !== jid ? chatName : undefined) ||
|
|
||||||
(fallbackName && fallbackName !== jid ? fallbackName : undefined) ||
|
|
||||||
jid;
|
|
||||||
|
|
||||||
if (jid.startsWith('dc:') && base !== jid && !base.startsWith('#')) {
|
|
||||||
return `#${base}`;
|
|
||||||
}
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeLocalStatusSnapshot(): void {
|
|
||||||
const jids = Object.keys(registeredGroups);
|
|
||||||
const statuses = queue.getStatuses(jids);
|
|
||||||
|
|
||||||
writeStatusSnapshot({
|
|
||||||
agentType: SERVICE_AGENT_TYPE,
|
|
||||||
assistantName: ASSISTANT_NAME,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
entries: statuses
|
|
||||||
.map((status) => {
|
|
||||||
const group = registeredGroups[status.jid];
|
|
||||||
if (!group) return null;
|
|
||||||
return {
|
|
||||||
jid: status.jid,
|
|
||||||
name: group.name,
|
|
||||||
folder: group.folder,
|
|
||||||
agentType: (group.agentType || SERVICE_AGENT_TYPE) as
|
|
||||||
| 'claude-code'
|
|
||||||
| 'codex',
|
|
||||||
status: status.status,
|
|
||||||
elapsedMs: status.elapsedMs,
|
|
||||||
pendingMessages: status.pendingMessages,
|
|
||||||
pendingTasks: status.pendingTasks,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(
|
|
||||||
(
|
|
||||||
entry,
|
|
||||||
): entry is {
|
|
||||||
jid: string;
|
|
||||||
name: string;
|
|
||||||
folder: string;
|
|
||||||
agentType: 'claude-code' | 'codex';
|
|
||||||
status: 'processing' | 'waiting' | 'inactive';
|
|
||||||
elapsedMs: number | null;
|
|
||||||
pendingMessages: boolean;
|
|
||||||
pendingTasks: number;
|
|
||||||
} => Boolean(entry),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStatusContent(): string {
|
|
||||||
if (!STATUS_SHOW_ROOMS) return '';
|
|
||||||
|
|
||||||
const snapshots = readStatusSnapshots(STATUS_SNAPSHOT_MAX_AGE_MS);
|
|
||||||
const chatNameByJid = new Map(
|
|
||||||
getAllChats().map((chat) => [chat.jid, chat.name]),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Collect all entries keyed by jid, with agent type info
|
|
||||||
interface RoomEntry {
|
|
||||||
agentType: 'claude-code' | 'codex';
|
|
||||||
status: 'processing' | 'waiting' | 'inactive';
|
|
||||||
elapsedMs: number | null;
|
|
||||||
pendingMessages: boolean;
|
|
||||||
pendingTasks: number;
|
|
||||||
name: string;
|
|
||||||
meta: ChannelMeta | undefined;
|
|
||||||
}
|
|
||||||
const byJid = new Map<string, RoomEntry[]>();
|
|
||||||
|
|
||||||
for (const snapshot of snapshots) {
|
|
||||||
const agentType = snapshot.agentType as 'claude-code' | 'codex';
|
|
||||||
for (const entry of snapshot.entries) {
|
|
||||||
const arr = byJid.get(entry.jid) || [];
|
|
||||||
arr.push({
|
|
||||||
agentType,
|
|
||||||
status: entry.status,
|
|
||||||
elapsedMs: entry.elapsedMs,
|
|
||||||
pendingMessages: entry.pendingMessages,
|
|
||||||
pendingTasks: entry.pendingTasks,
|
|
||||||
name: entry.name,
|
|
||||||
meta: channelMetaCache.get(entry.jid),
|
|
||||||
});
|
|
||||||
byJid.set(entry.jid, arr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group by category, then render rooms
|
|
||||||
interface RoomInfo {
|
|
||||||
jid: string;
|
|
||||||
name: string;
|
|
||||||
meta: ChannelMeta | undefined;
|
|
||||||
agents: RoomEntry[];
|
|
||||||
}
|
|
||||||
const categoryMap = new Map<string, RoomInfo[]>();
|
|
||||||
let totalActive = 0;
|
|
||||||
let totalRooms = 0;
|
|
||||||
|
|
||||||
for (const [jid, agents] of byJid) {
|
|
||||||
const meta = agents[0]?.meta;
|
|
||||||
const cat = meta?.category || '기타';
|
|
||||||
if (!categoryMap.has(cat)) categoryMap.set(cat, []);
|
|
||||||
categoryMap.get(cat)!.push({
|
|
||||||
jid,
|
|
||||||
name: formatRoomName(
|
|
||||||
jid,
|
|
||||||
meta,
|
|
||||||
agents.find((agent) => agent.name && agent.name !== jid)?.name,
|
|
||||||
chatNameByJid.get(jid),
|
|
||||||
),
|
|
||||||
meta,
|
|
||||||
agents,
|
|
||||||
});
|
|
||||||
totalRooms++;
|
|
||||||
if (agents.some((a) => a.status === 'processing')) totalActive++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedCategories = [...categoryMap.entries()].sort((a, b) => {
|
|
||||||
const posA = a[1][0]?.meta?.categoryPosition ?? 999;
|
|
||||||
const posB = b[1][0]?.meta?.categoryPosition ?? 999;
|
|
||||||
return posA - posB;
|
|
||||||
});
|
|
||||||
|
|
||||||
const roomLines = [];
|
|
||||||
for (const [catName, rooms] of sortedCategories) {
|
|
||||||
rooms.sort((a, b) => (a.meta?.position ?? 999) - (b.meta?.position ?? 999));
|
|
||||||
for (const room of rooms) {
|
|
||||||
// Sort agents: claude-code first, codex second
|
|
||||||
room.agents.sort((a, b) =>
|
|
||||||
a.agentType === b.agentType
|
|
||||||
? 0
|
|
||||||
: a.agentType === 'claude-code'
|
|
||||||
? -1
|
|
||||||
: 1,
|
|
||||||
);
|
|
||||||
|
|
||||||
const agentParts = room.agents.map((a) => {
|
|
||||||
const icon = STATUS_ICONS[a.status] || '⚪';
|
|
||||||
const label = getStatusLabel(a);
|
|
||||||
const tag = getAgentDisplayName(a.agentType);
|
|
||||||
return `${tag} ${icon} ${label}`;
|
|
||||||
});
|
|
||||||
roomLines.push({
|
|
||||||
category: catName,
|
|
||||||
categoryPosition: room.meta?.categoryPosition ?? 999,
|
|
||||||
position: room.meta?.position ?? 999,
|
|
||||||
line: ` **${room.name}** — ${agentParts.join(' | ')}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const header = `**📊 에이전트 상태** — 활성 ${totalActive} / ${totalRooms}`;
|
|
||||||
const sections = renderCategorizedRoomSections({
|
|
||||||
lines: roomLines,
|
|
||||||
showCategoryHeaders: channelMetaCache.size > 0,
|
|
||||||
});
|
|
||||||
return `${header}\n\n${sections}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── API Usage Fetchers ──────────────────────────────────────────
|
|
||||||
|
|
||||||
interface ClaudeUsageData {
|
|
||||||
five_hour?: { utilization: number; resets_at: string };
|
|
||||||
seven_day?: { utilization: number; resets_at: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CodexRateLimit {
|
|
||||||
limitId?: string;
|
|
||||||
limitName: string | null;
|
|
||||||
primary: { usedPercent: number; resetsAt: string | number };
|
|
||||||
secondary: { usedPercent: number; resetsAt: string | number };
|
|
||||||
}
|
|
||||||
|
|
||||||
let usageApiBackoffUntil = 0;
|
|
||||||
let usageApi429Streak = 0;
|
|
||||||
let usageApiPollingDisabled = false;
|
|
||||||
|
|
||||||
function parseRetryAfterMs(retryAfter: string | null): number | null {
|
|
||||||
if (!retryAfter) return null;
|
|
||||||
|
|
||||||
const seconds = Number(retryAfter);
|
|
||||||
if (Number.isFinite(seconds) && seconds > 0) {
|
|
||||||
return seconds * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
const absolute = Date.parse(retryAfter);
|
|
||||||
if (!Number.isNaN(absolute)) {
|
|
||||||
return Math.max(0, absolute - Date.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchClaudeUsage(): Promise<ClaudeUsageData | null> {
|
|
||||||
if (usageApiPollingDisabled) {
|
|
||||||
logger.debug('Skipping usage API call (polling disabled for this process)');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip if in backoff period (after 429)
|
|
||||||
if (Date.now() < usageApiBackoffUntil) {
|
|
||||||
logger.debug('Skipping usage API call (backoff active)');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const envToken = readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN']);
|
|
||||||
let token =
|
|
||||||
process.env.CLAUDE_CODE_OAUTH_TOKEN ||
|
|
||||||
envToken.CLAUDE_CODE_OAUTH_TOKEN ||
|
|
||||||
'';
|
|
||||||
if (!token) {
|
|
||||||
const configDir =
|
|
||||||
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
||||||
const credsPath = path.join(configDir, '.credentials.json');
|
|
||||||
if (!fs.existsSync(credsPath)) return null;
|
|
||||||
const creds = JSON.parse(fs.readFileSync(credsPath, 'utf-8'));
|
|
||||||
token = creds?.claudeAiOauth?.accessToken || '';
|
|
||||||
}
|
|
||||||
if (!token) return null;
|
|
||||||
|
|
||||||
const res = await fetch('https://api.anthropic.com/api/oauth/usage', {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'anthropic-beta': 'oauth-2025-04-20',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.text().catch(() => '');
|
|
||||||
if (res.status === 429) {
|
|
||||||
const retryAfter = res.headers.get('retry-after');
|
|
||||||
const retryAfterMs = parseRetryAfterMs(retryAfter);
|
|
||||||
const backoffMs = Math.max(600_000, retryAfterMs ?? 0);
|
|
||||||
usageApi429Streak += 1;
|
|
||||||
usageApiBackoffUntil = Date.now() + backoffMs;
|
|
||||||
if (usageApi429Streak >= 3) {
|
|
||||||
usageApiPollingDisabled = true;
|
|
||||||
}
|
|
||||||
logger.warn(
|
|
||||||
{
|
|
||||||
status: 429,
|
|
||||||
retryAfter,
|
|
||||||
retryAfterMs,
|
|
||||||
backoffMs,
|
|
||||||
consecutive429s: usageApi429Streak,
|
|
||||||
pollingDisabled: usageApiPollingDisabled,
|
|
||||||
body: body.slice(0, 200),
|
|
||||||
},
|
|
||||||
usageApiPollingDisabled
|
|
||||||
? 'Usage API rate limited repeatedly (429), disabling usage polling for this process'
|
|
||||||
: 'Usage API rate limited (429), backing off',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
logger.warn(
|
|
||||||
{ status: res.status, body: body.slice(0, 200) },
|
|
||||||
'Usage API returned non-OK status',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
usageApi429Streak = 0;
|
|
||||||
return (await res.json()) as ClaudeUsageData;
|
|
||||||
} catch (err) {
|
|
||||||
logger.debug({ err }, 'Usage API fetch failed');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchCodexUsage(): Promise<CodexRateLimit[] | null> {
|
|
||||||
// Find codex binary
|
|
||||||
const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin', 'codex');
|
|
||||||
const codexBin = fs.existsSync(npmGlobalBin) ? npmGlobalBin : 'codex';
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
let done = false;
|
|
||||||
const finish = (val: CodexRateLimit[] | null) => {
|
|
||||||
if (done) return;
|
|
||||||
done = true;
|
|
||||||
clearTimeout(timer);
|
|
||||||
try {
|
|
||||||
proc.kill();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
resolve(val);
|
|
||||||
};
|
|
||||||
|
|
||||||
const timer = setTimeout(() => finish(null), 20000);
|
|
||||||
|
|
||||||
let proc: ChildProcess;
|
|
||||||
try {
|
|
||||||
proc = spawn(codexBin, ['app-server'], {
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
env: {
|
|
||||||
...(process.env as Record<string, string>),
|
|
||||||
PATH: [
|
|
||||||
path.dirname(process.execPath),
|
|
||||||
path.join(os.homedir(), '.npm-global', 'bin'),
|
|
||||||
process.env.PATH || '',
|
|
||||||
].join(':'),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
resolve(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
proc.on('error', () => finish(null));
|
|
||||||
proc.on('close', () => finish(null));
|
|
||||||
|
|
||||||
let buf = '';
|
|
||||||
proc.stdout!.on('data', (chunk: Buffer) => {
|
|
||||||
buf += chunk.toString();
|
|
||||||
const lines = buf.split('\n');
|
|
||||||
buf = lines.pop() || '';
|
|
||||||
for (const line of lines) {
|
|
||||||
if (!line.trim()) continue;
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(line);
|
|
||||||
if (msg.id === 1) {
|
|
||||||
// Initialize done, query rate limits
|
|
||||||
proc.stdin!.write(
|
|
||||||
JSON.stringify({
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
id: 2,
|
|
||||||
method: 'account/rateLimits/read',
|
|
||||||
params: {},
|
|
||||||
}) + '\n',
|
|
||||||
);
|
|
||||||
} else if (msg.id === 2 && msg.result) {
|
|
||||||
// Extract rate limits from rateLimitsByLimitId object
|
|
||||||
const byId = msg.result.rateLimitsByLimitId;
|
|
||||||
if (byId && typeof byId === 'object') {
|
|
||||||
finish(Object.values(byId) as CodexRateLimit[]);
|
|
||||||
} else {
|
|
||||||
finish(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* non-JSON line, skip */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send initialize
|
|
||||||
proc.stdin!.write(
|
|
||||||
JSON.stringify({
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
id: 1,
|
|
||||||
method: 'initialize',
|
|
||||||
params: { clientInfo: { name: 'usage-monitor', version: '1.0' } },
|
|
||||||
}) + '\n',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Usage Dashboard Builder ─────────────────────────────────────
|
|
||||||
|
|
||||||
async function buildUsageContent(): Promise<string> {
|
|
||||||
const lines: string[] = [];
|
|
||||||
const activeSnapshots = readStatusSnapshots(STATUS_SNAPSHOT_MAX_AGE_MS);
|
|
||||||
const hasActiveClaudeWork = activeSnapshots.some(
|
|
||||||
(snapshot) =>
|
|
||||||
snapshot.agentType === 'claude-code' &&
|
|
||||||
snapshot.entries.some(
|
|
||||||
(entry) => entry.status === 'processing' || entry.status === 'waiting',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const shouldFetchClaudeUsage = USAGE_DASHBOARD_ENABLED;
|
|
||||||
|
|
||||||
const [liveClaudeUsage, codexUsage] = await Promise.all([
|
|
||||||
shouldFetchClaudeUsage && !hasActiveClaudeWork
|
|
||||||
? fetchClaudeUsage()
|
|
||||||
: Promise.resolve(null),
|
|
||||||
fetchCodexUsage(),
|
|
||||||
]);
|
|
||||||
const claudeUsage = shouldFetchClaudeUsage
|
|
||||||
? liveClaudeUsage || cachedClaudeUsageData
|
|
||||||
: null;
|
|
||||||
const claudeUsageIsCached =
|
|
||||||
shouldFetchClaudeUsage && !liveClaudeUsage && !!cachedClaudeUsageData;
|
|
||||||
if (shouldFetchClaudeUsage && liveClaudeUsage) {
|
|
||||||
cachedClaudeUsageData = liveClaudeUsage;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bar = (pct: number) => {
|
|
||||||
const filled = Math.round(pct / 10);
|
|
||||||
return '█'.repeat(filled) + '░'.repeat(10 - filled);
|
|
||||||
};
|
|
||||||
|
|
||||||
lines.push('📊 *사용량*');
|
|
||||||
|
|
||||||
type UsageRow = {
|
|
||||||
name: string;
|
|
||||||
h5pct: number;
|
|
||||||
h5reset: string;
|
|
||||||
d7pct: number;
|
|
||||||
d7reset: string;
|
|
||||||
};
|
|
||||||
const rows: UsageRow[] = [];
|
|
||||||
|
|
||||||
if (claudeUsage) {
|
|
||||||
const h5 = claudeUsage.five_hour;
|
|
||||||
const d7 = claudeUsage.seven_day;
|
|
||||||
rows.push({
|
|
||||||
name: claudeUsageIsCached ? 'Claude*' : 'Claude',
|
|
||||||
h5pct: h5
|
|
||||||
? h5.utilization > 1
|
|
||||||
? Math.round(h5.utilization)
|
|
||||||
: Math.round(h5.utilization * 100)
|
|
||||||
: -1,
|
|
||||||
h5reset: h5 ? formatResetKST(h5.resets_at) : '',
|
|
||||||
d7pct: d7
|
|
||||||
? d7.utilization > 1
|
|
||||||
? Math.round(d7.utilization)
|
|
||||||
: Math.round(d7.utilization * 100)
|
|
||||||
: -1,
|
|
||||||
d7reset: d7 ? formatResetKST(d7.resets_at) : '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (codexUsage && Array.isArray(codexUsage)) {
|
|
||||||
const relevant = codexUsage.filter(
|
|
||||||
(limit) =>
|
|
||||||
limit.primary.usedPercent > 0 || limit.secondary.usedPercent > 0,
|
|
||||||
);
|
|
||||||
const display = relevant.length > 0 ? relevant : codexUsage.slice(0, 1);
|
|
||||||
for (const limit of display) {
|
|
||||||
rows.push({
|
|
||||||
name: 'Codex',
|
|
||||||
h5pct: Math.round(limit.primary.usedPercent),
|
|
||||||
h5reset: formatResetKST(limit.primary.resetsAt),
|
|
||||||
d7pct: Math.round(limit.secondary.usedPercent),
|
|
||||||
d7reset: formatResetKST(limit.secondary.resetsAt),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
|
||||||
lines.push('```');
|
|
||||||
lines.push(' 5-Hour 7-Day');
|
|
||||||
for (const row of rows) {
|
|
||||||
const h5 =
|
|
||||||
row.h5pct >= 0
|
|
||||||
? `${bar(row.h5pct)} ${String(row.h5pct).padStart(3)}%`
|
|
||||||
: ' — ';
|
|
||||||
const d7 =
|
|
||||||
row.d7pct >= 0
|
|
||||||
? `${bar(row.d7pct)} ${String(row.d7pct).padStart(3)}%`
|
|
||||||
: ' — ';
|
|
||||||
lines.push(`${row.name.padEnd(8)}${h5} ${d7}`);
|
|
||||||
}
|
|
||||||
lines.push('```');
|
|
||||||
} else {
|
|
||||||
lines.push('_조회 불가_');
|
|
||||||
}
|
|
||||||
if (shouldFetchClaudeUsage && usageApiPollingDisabled) {
|
|
||||||
lines.push(
|
|
||||||
'_* Claude 사용량 조회는 반복된 429로 이번 프로세스에서 일시 중지_',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (claudeUsageIsCached) {
|
|
||||||
lines.push('_* Claude 사용량은 작업 중일 때는 캐시값 유지_');
|
|
||||||
}
|
|
||||||
lines.push('');
|
|
||||||
|
|
||||||
lines.push('🖥️ *서버*');
|
|
||||||
|
|
||||||
const loadAvg = os.loadavg();
|
|
||||||
const cpuCount = os.cpus().length;
|
|
||||||
const cpuPct = Math.round((loadAvg[1] / cpuCount) * 100);
|
|
||||||
|
|
||||||
const totalMem = os.totalmem();
|
|
||||||
const usedMem = totalMem - os.freemem();
|
|
||||||
const memPct = Math.round((usedMem / totalMem) * 100);
|
|
||||||
const memUsedGB = (usedMem / 1073741824).toFixed(1);
|
|
||||||
const memTotalGB = (totalMem / 1073741824).toFixed(1);
|
|
||||||
|
|
||||||
let diskPct = 0;
|
|
||||||
let diskUsedGB = '?';
|
|
||||||
let diskTotalGB = '?';
|
|
||||||
try {
|
|
||||||
const df = execSync('df -B1 / | tail -1', {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
timeout: 5000,
|
|
||||||
}).trim();
|
|
||||||
const parts = df.split(/\s+/);
|
|
||||||
const diskUsed = parseInt(parts[2], 10);
|
|
||||||
const diskTotal = parseInt(parts[1], 10);
|
|
||||||
diskPct = Math.round((diskUsed / diskTotal) * 100);
|
|
||||||
diskUsedGB = (diskUsed / 1073741824).toFixed(0);
|
|
||||||
diskTotalGB = (diskTotal / 1073741824).toFixed(0);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push('```');
|
|
||||||
lines.push(`${'CPU'.padEnd(8)}${bar(cpuPct)} ${String(cpuPct).padStart(3)}%`);
|
|
||||||
lines.push(
|
|
||||||
`${'Memory'.padEnd(8)}${bar(memPct)} ${String(memPct).padStart(3)}% ${memUsedGB}/${memTotalGB}GB`,
|
|
||||||
);
|
|
||||||
lines.push(
|
|
||||||
`${'Disk'.padEnd(8)}${bar(diskPct)} ${String(diskPct).padStart(3)}% ${diskUsedGB}/${diskTotalGB}GB`,
|
|
||||||
);
|
|
||||||
lines.push(`${'Uptime'.padEnd(8)}${formatElapsed(os.uptime() * 1000)}`);
|
|
||||||
lines.push('```');
|
|
||||||
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Unified Dashboard Lifecycle ──────────────────────────────────
|
|
||||||
|
|
||||||
let usageUpdateInProgress = false;
|
|
||||||
|
|
||||||
async function refreshUsageCache(): Promise<void> {
|
|
||||||
if (usageUpdateInProgress) return;
|
|
||||||
usageUpdateInProgress = true;
|
|
||||||
try {
|
|
||||||
cachedUsageContent = await buildUsageContent();
|
|
||||||
} catch {
|
|
||||||
/* keep previous cache */
|
|
||||||
} finally {
|
|
||||||
usageUpdateInProgress = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildUnifiedDashboard(): string {
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (STATUS_SHOW_ROOMS) {
|
|
||||||
parts.push(buildStatusContent());
|
|
||||||
}
|
|
||||||
if (cachedUsageContent) {
|
|
||||||
parts.push(cachedUsageContent);
|
|
||||||
}
|
|
||||||
return composeDashboardContent(parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startStatusDashboard(): Promise<void> {
|
|
||||||
if (!STATUS_CHANNEL_ID) return;
|
|
||||||
const isRenderer = SERVICE_AGENT_TYPE === 'claude-code';
|
|
||||||
|
|
||||||
const statusJid = `dc:${STATUS_CHANNEL_ID}`;
|
|
||||||
|
|
||||||
const findDiscordChannel = () =>
|
|
||||||
channels.find((c) => c.name.startsWith('discord') && c.isConnected());
|
|
||||||
|
|
||||||
// Initial usage fetch
|
|
||||||
if (isRenderer) {
|
|
||||||
await refreshUsageCache();
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateStatus = async () => {
|
|
||||||
writeLocalStatusSnapshot();
|
|
||||||
if (!isRenderer) return;
|
|
||||||
|
|
||||||
const ch = findDiscordChannel();
|
|
||||||
if (!ch) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await refreshChannelMeta();
|
|
||||||
const content = buildUnifiedDashboard();
|
|
||||||
if (!content) {
|
|
||||||
statusMessageId = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statusMessageId && ch.editMessage) {
|
|
||||||
await ch.editMessage(statusJid, statusMessageId, content);
|
|
||||||
} else if (ch.sendAndTrack) {
|
|
||||||
const id = await ch.sendAndTrack(statusJid, content);
|
|
||||||
if (id) statusMessageId = id;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.debug({ err }, 'Dashboard update failed');
|
|
||||||
statusMessageId = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Status updates every 10s
|
|
||||||
setInterval(updateStatus, STATUS_UPDATE_INTERVAL);
|
|
||||||
await updateStatus();
|
|
||||||
|
|
||||||
// Usage cache refreshes every 5min (only on renderer)
|
|
||||||
if (isRenderer) {
|
|
||||||
setInterval(refreshUsageCache, USAGE_UPDATE_INTERVAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
{ channelId: STATUS_CHANNEL_ID, isRenderer, agentType: SERVICE_AGENT_TYPE },
|
|
||||||
isRenderer
|
|
||||||
? 'Unified dashboard started'
|
|
||||||
: 'Status snapshot updater started',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy compat — now handled inside startStatusDashboard
|
|
||||||
async function startUsageDashboard(): Promise<void> {
|
|
||||||
// Usage is now integrated into the unified dashboard
|
|
||||||
}
|
|
||||||
|
|
||||||
async function announceRestartRecovery(
|
async function announceRestartRecovery(
|
||||||
processStartedAtMs: number,
|
processStartedAtMs: number,
|
||||||
): Promise<RestartContext | null> {
|
): Promise<RestartContext | null> {
|
||||||
@@ -1171,18 +440,23 @@ async function main(): Promise<void> {
|
|||||||
'Queued interrupted group for restart recovery',
|
'Queued interrupted group for restart recovery',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Purge old messages in status channel before creating fresh dashboards
|
await startUnifiedDashboard({
|
||||||
if (STATUS_CHANNEL_ID && SERVICE_AGENT_TYPE === 'claude-code') {
|
assistantName: ASSISTANT_NAME,
|
||||||
const statusJid = `dc:${STATUS_CHANNEL_ID}`;
|
serviceAgentType: SERVICE_AGENT_TYPE,
|
||||||
const ch = channels.find(
|
statusChannelId: STATUS_CHANNEL_ID,
|
||||||
(c) => c.name.startsWith('discord') && c.isConnected() && c.purgeChannel,
|
statusUpdateInterval: STATUS_UPDATE_INTERVAL,
|
||||||
);
|
usageUpdateInterval: USAGE_UPDATE_INTERVAL,
|
||||||
if (ch?.purgeChannel) {
|
channels,
|
||||||
await ch.purgeChannel(statusJid);
|
queue,
|
||||||
}
|
registeredGroups: () => registeredGroups,
|
||||||
}
|
onGroupNameSynced: (jid, name) => {
|
||||||
await startStatusDashboard();
|
const group = registeredGroups[jid];
|
||||||
await startUsageDashboard();
|
if (group) {
|
||||||
|
group.name = name;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
purgeOnStart: true,
|
||||||
|
});
|
||||||
runtime.startMessageLoop().catch((err) => {
|
runtime.startMessageLoop().catch((err) => {
|
||||||
logger.fatal({ err }, 'Message loop crashed unexpectedly');
|
logger.fatal({ err }, 'Message loop crashed unexpectedly');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { _initTestDatabase, createTask, getTaskById } from './db.js';
|
import { _initTestDatabase, createTask, getTaskById } from './db.js';
|
||||||
|
import { createTaskStatusTracker } from './task-status-tracker.js';
|
||||||
|
import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js';
|
||||||
import {
|
import {
|
||||||
_resetSchedulerLoopForTests,
|
_resetSchedulerLoopForTests,
|
||||||
computeNextRun,
|
computeNextRun,
|
||||||
@@ -274,6 +276,71 @@ Check the run.
|
|||||||
expect(rendered).not.toContain('- 경과 시간:');
|
expect(rendered).not.toContain('- 경과 시간:');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('edits the existing watcher status message with refreshed elapsed time', async () => {
|
||||||
|
vi.setSystemTime(new Date('2026-03-19T07:00:00.000Z'));
|
||||||
|
|
||||||
|
createTask({
|
||||||
|
id: 'task-watch-status',
|
||||||
|
group_folder: 'shared-group',
|
||||||
|
chat_jid: 'shared@g.us',
|
||||||
|
agent_type: 'codex',
|
||||||
|
prompt: `
|
||||||
|
[BACKGROUND CI WATCH]
|
||||||
|
|
||||||
|
Watch target:
|
||||||
|
GitHub Actions run 123456
|
||||||
|
|
||||||
|
Task ID:
|
||||||
|
task-watch-status
|
||||||
|
|
||||||
|
Check instructions:
|
||||||
|
Check the run.
|
||||||
|
`.trim(),
|
||||||
|
schedule_type: 'interval',
|
||||||
|
schedule_value: '60000',
|
||||||
|
context_mode: 'isolated',
|
||||||
|
next_run: new Date(Date.now() - 60_000).toISOString(),
|
||||||
|
status: 'active',
|
||||||
|
created_at: '2026-02-22T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendTrackedMessage = vi.fn(async () => 'msg-123');
|
||||||
|
const editTrackedMessage = vi.fn(async () => {});
|
||||||
|
|
||||||
|
const tracker = createTaskStatusTracker(getTaskById('task-watch-status')!, {
|
||||||
|
sendTrackedMessage,
|
||||||
|
editTrackedMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
await tracker.update('checking');
|
||||||
|
|
||||||
|
const firstState = getTaskById('task-watch-status');
|
||||||
|
expect(sendTrackedMessage).toHaveBeenCalledWith(
|
||||||
|
'shared@g.us',
|
||||||
|
expect.stringContaining(`${TASK_STATUS_MESSAGE_PREFIX}CI 감시 중:`),
|
||||||
|
);
|
||||||
|
expect(firstState?.status_message_id).toBe('msg-123');
|
||||||
|
expect(firstState?.status_started_at).toBe('2026-03-19T07:00:00.000Z');
|
||||||
|
|
||||||
|
vi.setSystemTime(new Date('2026-03-19T07:02:10.000Z'));
|
||||||
|
await tracker.update('waiting', '2026-03-19T07:04:10.000Z');
|
||||||
|
|
||||||
|
expect(editTrackedMessage).toHaveBeenCalledWith(
|
||||||
|
'shared@g.us',
|
||||||
|
'msg-123',
|
||||||
|
expect.stringContaining('- 경과 시간: 2분 10초'),
|
||||||
|
);
|
||||||
|
expect(editTrackedMessage).toHaveBeenCalledWith(
|
||||||
|
'shared@g.us',
|
||||||
|
'msg-123',
|
||||||
|
expect.stringContaining('- 다음 확인: 16시 04분 10초'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const secondState = getTaskById('task-watch-status');
|
||||||
|
expect(secondState?.status_message_id).toBe('msg-123');
|
||||||
|
expect(secondState?.status_started_at).toBe('2026-03-19T07:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
it('computeNextRun anchors interval tasks to scheduled time to prevent drift', () => {
|
it('computeNextRun anchors interval tasks to scheduled time to prevent drift', () => {
|
||||||
const scheduledTime = new Date(Date.now() - 2000).toISOString(); // 2s ago
|
const scheduledTime = new Date(Date.now() - 2000).toISOString(); // 2s ago
|
||||||
const task = {
|
const task = {
|
||||||
|
|||||||
@@ -18,20 +18,14 @@ import {
|
|||||||
getDueTasks,
|
getDueTasks,
|
||||||
getTaskById,
|
getTaskById,
|
||||||
logTaskRun,
|
logTaskRun,
|
||||||
updateTaskStatusTracking,
|
|
||||||
updateTask,
|
updateTask,
|
||||||
updateTaskAfterRun,
|
updateTaskAfterRun,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import { GroupQueue } from './group-queue.js';
|
import { GroupQueue } from './group-queue.js';
|
||||||
import { resolveGroupFolderPath } from './group-folder.js';
|
import { resolveGroupFolderPath } from './group-folder.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import {
|
import { createTaskStatusTracker } from './task-status-tracker.js';
|
||||||
getTaskQueueJid,
|
import { getTaskQueueJid } from './task-watch-status.js';
|
||||||
isWatchCiTask,
|
|
||||||
renderWatchCiStatusMessage,
|
|
||||||
TASK_STATUS_MESSAGE_PREFIX,
|
|
||||||
type WatcherStatusPhase,
|
|
||||||
} from './task-watch-status.js';
|
|
||||||
import { AgentType, RegisteredGroup, ScheduledTask } from './types.js';
|
import { AgentType, RegisteredGroup, ScheduledTask } from './types.js';
|
||||||
export {
|
export {
|
||||||
extractWatchCiTarget,
|
extractWatchCiTarget,
|
||||||
@@ -102,22 +96,90 @@ export interface SchedulerDependencies {
|
|||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TaskExecutionContext {
|
||||||
|
group: RegisteredGroup;
|
||||||
|
groupDir: string;
|
||||||
|
isMain: boolean;
|
||||||
|
queueJid: string;
|
||||||
|
sessionId?: string;
|
||||||
|
taskAgentType: AgentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTaskExecutionContext(
|
||||||
|
task: ScheduledTask,
|
||||||
|
deps: SchedulerDependencies,
|
||||||
|
): TaskExecutionContext {
|
||||||
|
const groupDir = resolveGroupFolderPath(task.group_folder);
|
||||||
|
fs.mkdirSync(groupDir, { recursive: true });
|
||||||
|
|
||||||
|
const groups = deps.registeredGroups();
|
||||||
|
const group = Object.values(groups).find(
|
||||||
|
(registeredGroup) => registeredGroup.folder === task.group_folder,
|
||||||
|
);
|
||||||
|
if (!group) {
|
||||||
|
throw new Error(`Group not found: ${task.group_folder}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMain = group.isMain === true;
|
||||||
|
const taskAgentType =
|
||||||
|
task.agent_type || deps.serviceAgentType || SERVICE_AGENT_TYPE;
|
||||||
|
const sessions = deps.getSessions();
|
||||||
|
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
groupDir,
|
||||||
|
isMain,
|
||||||
|
queueJid: getTaskQueueJid(task),
|
||||||
|
sessionId:
|
||||||
|
task.context_mode === 'group' ? sessions[task.group_folder] : undefined,
|
||||||
|
taskAgentType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeTaskSnapshotForGroup(
|
||||||
|
taskAgentType: AgentType,
|
||||||
|
groupFolder: string,
|
||||||
|
isMain: boolean,
|
||||||
|
): void {
|
||||||
|
const tasks = getAllTasks(taskAgentType);
|
||||||
|
writeTasksSnapshot(
|
||||||
|
groupFolder,
|
||||||
|
isMain,
|
||||||
|
tasks.map((task) => ({
|
||||||
|
id: task.id,
|
||||||
|
groupFolder: task.group_folder,
|
||||||
|
prompt: task.prompt,
|
||||||
|
schedule_type: task.schedule_type,
|
||||||
|
schedule_value: task.schedule_value,
|
||||||
|
status: task.status,
|
||||||
|
next_run: task.next_run,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function runTask(
|
async function runTask(
|
||||||
task: ScheduledTask,
|
task: ScheduledTask,
|
||||||
deps: SchedulerDependencies,
|
deps: SchedulerDependencies,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
let groupDir: string;
|
let context: TaskExecutionContext;
|
||||||
try {
|
try {
|
||||||
groupDir = resolveGroupFolderPath(task.group_folder);
|
context = resolveTaskExecutionContext(task, deps);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err instanceof Error ? err.message : String(err);
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
// Stop retry churn for malformed legacy rows.
|
if (error.startsWith('Group not found:')) {
|
||||||
updateTask(task.id, { status: 'paused' });
|
logger.error(
|
||||||
logger.error(
|
{ taskId: task.id, groupFolder: task.group_folder, error },
|
||||||
{ taskId: task.id, groupFolder: task.group_folder, error },
|
'Group not found for task',
|
||||||
'Task has invalid group folder',
|
);
|
||||||
);
|
} else {
|
||||||
|
// Stop retry churn for malformed legacy rows.
|
||||||
|
updateTask(task.id, { status: 'paused' });
|
||||||
|
logger.error(
|
||||||
|
{ taskId: task.id, groupFolder: task.group_folder, error },
|
||||||
|
'Task has invalid group folder',
|
||||||
|
);
|
||||||
|
}
|
||||||
logTaskRun({
|
logTaskRun({
|
||||||
task_id: task.id,
|
task_id: task.id,
|
||||||
run_at: new Date().toISOString(),
|
run_at: new Date().toISOString(),
|
||||||
@@ -128,134 +190,42 @@ async function runTask(
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fs.mkdirSync(groupDir, { recursive: true });
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{ taskId: task.id, group: task.group_folder },
|
{ taskId: task.id, group: task.group_folder },
|
||||||
'Running scheduled task',
|
'Running scheduled task',
|
||||||
);
|
);
|
||||||
|
|
||||||
const groups = deps.registeredGroups();
|
|
||||||
const group = Object.values(groups).find(
|
|
||||||
(g) => g.folder === task.group_folder,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!group) {
|
|
||||||
logger.error(
|
|
||||||
{ taskId: task.id, groupFolder: task.group_folder },
|
|
||||||
'Group not found for task',
|
|
||||||
);
|
|
||||||
logTaskRun({
|
|
||||||
task_id: task.id,
|
|
||||||
run_at: new Date().toISOString(),
|
|
||||||
duration_ms: Date.now() - startTime,
|
|
||||||
status: 'error',
|
|
||||||
result: null,
|
|
||||||
error: `Group not found: ${task.group_folder}`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update tasks snapshot for agent to read (filtered by group)
|
// Update tasks snapshot for agent to read (filtered by group)
|
||||||
const isMain = group.isMain === true;
|
writeTaskSnapshotForGroup(
|
||||||
const taskAgentType =
|
context.taskAgentType,
|
||||||
task.agent_type || deps.serviceAgentType || SERVICE_AGENT_TYPE;
|
|
||||||
const tasks = getAllTasks(taskAgentType);
|
|
||||||
writeTasksSnapshot(
|
|
||||||
task.group_folder,
|
task.group_folder,
|
||||||
isMain,
|
context.isMain,
|
||||||
tasks.map((t) => ({
|
|
||||||
id: t.id,
|
|
||||||
groupFolder: t.group_folder,
|
|
||||||
prompt: t.prompt,
|
|
||||||
schedule_type: t.schedule_type,
|
|
||||||
schedule_value: t.schedule_value,
|
|
||||||
status: t.status,
|
|
||||||
next_run: t.next_run,
|
|
||||||
})),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let result: string | null = null;
|
let result: string | null = null;
|
||||||
let error: string | null = null;
|
let error: string | null = null;
|
||||||
let statusMessageId = task.status_message_id;
|
const statusTracker = createTaskStatusTracker(task, {
|
||||||
let statusStartedAt = task.status_started_at;
|
sendTrackedMessage: deps.sendTrackedMessage,
|
||||||
const queueJid = getTaskQueueJid(task);
|
editTrackedMessage: deps.editTrackedMessage,
|
||||||
|
});
|
||||||
// For group context mode, use the group's current session
|
|
||||||
const sessions = deps.getSessions();
|
|
||||||
const sessionId =
|
|
||||||
task.context_mode === 'group' ? sessions[task.group_folder] : undefined;
|
|
||||||
|
|
||||||
const shouldTrackStatus =
|
|
||||||
isWatchCiTask(task) &&
|
|
||||||
typeof deps.sendTrackedMessage === 'function' &&
|
|
||||||
typeof deps.editTrackedMessage === 'function';
|
|
||||||
|
|
||||||
const persistStatusTracking = () => {
|
|
||||||
const currentTask = getTaskById(task.id);
|
|
||||||
if (!currentTask) return;
|
|
||||||
updateTaskStatusTracking(task.id, {
|
|
||||||
status_message_id: statusMessageId,
|
|
||||||
status_started_at: statusStartedAt,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateWatcherStatus = async (
|
|
||||||
phase: WatcherStatusPhase,
|
|
||||||
nextRun?: string | null,
|
|
||||||
) => {
|
|
||||||
if (!shouldTrackStatus) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkedAt = new Date().toISOString();
|
|
||||||
if (!statusStartedAt) {
|
|
||||||
statusStartedAt = checkedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = renderWatchCiStatusMessage({
|
|
||||||
task,
|
|
||||||
phase,
|
|
||||||
checkedAt,
|
|
||||||
statusStartedAt,
|
|
||||||
nextRun,
|
|
||||||
});
|
|
||||||
const payload = `${TASK_STATUS_MESSAGE_PREFIX}${text}`;
|
|
||||||
|
|
||||||
if (statusMessageId) {
|
|
||||||
try {
|
|
||||||
await deps.editTrackedMessage!(task.chat_jid, statusMessageId, payload);
|
|
||||||
persistStatusTracking();
|
|
||||||
return;
|
|
||||||
} catch {
|
|
||||||
statusMessageId = null;
|
|
||||||
persistStatusTracking();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const messageId = await deps.sendTrackedMessage!(task.chat_jid, payload);
|
|
||||||
if (messageId) {
|
|
||||||
statusMessageId = messageId;
|
|
||||||
persistStatusTracking();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateWatcherStatus('checking');
|
await statusTracker.update('checking');
|
||||||
|
|
||||||
const output = await runAgentProcess(
|
const output = await runAgentProcess(
|
||||||
group,
|
context.group,
|
||||||
{
|
{
|
||||||
prompt: task.prompt,
|
prompt: task.prompt,
|
||||||
sessionId,
|
sessionId: context.sessionId,
|
||||||
groupFolder: task.group_folder,
|
groupFolder: task.group_folder,
|
||||||
chatJid: task.chat_jid,
|
chatJid: task.chat_jid,
|
||||||
isMain,
|
isMain: context.isMain,
|
||||||
isScheduledTask: true,
|
isScheduledTask: true,
|
||||||
assistantName: ASSISTANT_NAME,
|
assistantName: ASSISTANT_NAME,
|
||||||
},
|
},
|
||||||
(proc, processName) =>
|
(proc, processName) =>
|
||||||
deps.onProcess(queueJid, proc, processName, task.group_folder),
|
deps.onProcess(context.queueJid, proc, processName, task.group_folder),
|
||||||
async (streamedOutput: AgentOutput) => {
|
async (streamedOutput: AgentOutput) => {
|
||||||
if (streamedOutput.phase === 'progress') {
|
if (streamedOutput.phase === 'progress') {
|
||||||
return;
|
return;
|
||||||
@@ -281,7 +251,7 @@ async function runTask(
|
|||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
agentType: taskAgentType,
|
agentType: context.taskAgentType,
|
||||||
durationMs: Date.now() - startTime,
|
durationMs: Date.now() - startTime,
|
||||||
},
|
},
|
||||||
'Task completed',
|
'Task completed',
|
||||||
@@ -296,7 +266,7 @@ async function runTask(
|
|||||||
const nextRun = currentTask ? computeNextRun(task) : null;
|
const nextRun = currentTask ? computeNextRun(task) : null;
|
||||||
|
|
||||||
if (!currentTask) {
|
if (!currentTask) {
|
||||||
await updateWatcherStatus('completed');
|
await statusTracker.update('completed');
|
||||||
logger.debug(
|
logger.debug(
|
||||||
{ taskId: task.id },
|
{ taskId: task.id },
|
||||||
'Task deleted during execution, skipping persistence',
|
'Task deleted during execution, skipping persistence',
|
||||||
@@ -305,11 +275,11 @@ async function runTask(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
await updateWatcherStatus('retrying', nextRun);
|
await statusTracker.update('retrying', nextRun);
|
||||||
} else if (nextRun) {
|
} else if (nextRun) {
|
||||||
await updateWatcherStatus('waiting', nextRun);
|
await statusTracker.update('waiting', nextRun);
|
||||||
} else {
|
} else {
|
||||||
await updateWatcherStatus('completed');
|
await statusTracker.update('completed');
|
||||||
}
|
}
|
||||||
|
|
||||||
logTaskRun({
|
logTaskRun({
|
||||||
|
|||||||
92
src/task-status-tracker.ts
Normal file
92
src/task-status-tracker.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { getTaskById, updateTaskStatusTracking } from './db.js';
|
||||||
|
import {
|
||||||
|
isWatchCiTask,
|
||||||
|
renderWatchCiStatusMessage,
|
||||||
|
TASK_STATUS_MESSAGE_PREFIX,
|
||||||
|
type WatcherStatusPhase,
|
||||||
|
} from './task-watch-status.js';
|
||||||
|
import type { ScheduledTask } from './types.js';
|
||||||
|
|
||||||
|
export interface TaskStatusTrackerTransport {
|
||||||
|
sendTrackedMessage?: (jid: string, text: string) => Promise<string | null>;
|
||||||
|
editTrackedMessage?: (
|
||||||
|
jid: string,
|
||||||
|
messageId: string,
|
||||||
|
text: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskStatusTracker {
|
||||||
|
enabled: boolean;
|
||||||
|
update: (
|
||||||
|
phase: WatcherStatusPhase,
|
||||||
|
nextRun?: string | null,
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTaskStatusTracker(
|
||||||
|
task: ScheduledTask,
|
||||||
|
transport: TaskStatusTrackerTransport,
|
||||||
|
): TaskStatusTracker {
|
||||||
|
let statusMessageId = task.status_message_id;
|
||||||
|
let statusStartedAt = task.status_started_at;
|
||||||
|
const enabled =
|
||||||
|
isWatchCiTask(task) &&
|
||||||
|
typeof transport.sendTrackedMessage === 'function' &&
|
||||||
|
typeof transport.editTrackedMessage === 'function';
|
||||||
|
|
||||||
|
const persist = () => {
|
||||||
|
const currentTask = getTaskById(task.id);
|
||||||
|
if (!currentTask) return;
|
||||||
|
updateTaskStatusTracking(task.id, {
|
||||||
|
status_message_id: statusMessageId,
|
||||||
|
status_started_at: statusStartedAt,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = async (
|
||||||
|
phase: WatcherStatusPhase,
|
||||||
|
nextRun?: string | null,
|
||||||
|
) => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
const checkedAt = new Date().toISOString();
|
||||||
|
if (!statusStartedAt) {
|
||||||
|
statusStartedAt = checkedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = `${TASK_STATUS_MESSAGE_PREFIX}${renderWatchCiStatusMessage({
|
||||||
|
task,
|
||||||
|
phase,
|
||||||
|
checkedAt,
|
||||||
|
statusStartedAt,
|
||||||
|
nextRun,
|
||||||
|
})}`;
|
||||||
|
|
||||||
|
if (statusMessageId) {
|
||||||
|
try {
|
||||||
|
await transport.editTrackedMessage!(
|
||||||
|
task.chat_jid,
|
||||||
|
statusMessageId,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
persist();
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
statusMessageId = null;
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMessageId = await transport.sendTrackedMessage!(
|
||||||
|
task.chat_jid,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
if (nextMessageId) {
|
||||||
|
statusMessageId = nextMessageId;
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { enabled, update };
|
||||||
|
}
|
||||||
742
src/unified-dashboard.ts
Normal file
742
src/unified-dashboard.ts
Normal file
@@ -0,0 +1,742 @@
|
|||||||
|
import { ChildProcess, execSync, spawn } from 'child_process';
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
STATUS_SHOW_ROOMS,
|
||||||
|
USAGE_DASHBOARD_ENABLED,
|
||||||
|
} from './config.js';
|
||||||
|
import {
|
||||||
|
fetchClaudeUsageViaCli,
|
||||||
|
type ClaudeUsageData,
|
||||||
|
} from './claude-usage.js';
|
||||||
|
import {
|
||||||
|
composeDashboardContent,
|
||||||
|
formatElapsed,
|
||||||
|
getStatusLabel as formatDashboardStatusLabel,
|
||||||
|
type DashboardRoomLine,
|
||||||
|
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 {
|
||||||
|
readStatusSnapshots,
|
||||||
|
writeStatusSnapshot,
|
||||||
|
} from './status-dashboard.js';
|
||||||
|
import type {
|
||||||
|
AgentType,
|
||||||
|
Channel,
|
||||||
|
ChannelMeta,
|
||||||
|
RegisteredGroup,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
export interface UnifiedDashboardOptions {
|
||||||
|
assistantName: string;
|
||||||
|
serviceAgentType: AgentType;
|
||||||
|
statusChannelId: string;
|
||||||
|
statusUpdateInterval: number;
|
||||||
|
usageUpdateInterval: number;
|
||||||
|
channels: Channel[];
|
||||||
|
queue: GroupQueue;
|
||||||
|
registeredGroups: () => Record<string, RegisteredGroup>;
|
||||||
|
onGroupNameSynced?: (jid: string, name: string) => void;
|
||||||
|
purgeOnStart?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodexRateLimit {
|
||||||
|
limitId?: string;
|
||||||
|
limitName: string | null;
|
||||||
|
primary: { usedPercent: number; resetsAt: string | number };
|
||||||
|
secondary: { usedPercent: number; resetsAt: string | number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_ICONS: Record<string, string> = {
|
||||||
|
processing: '🟡',
|
||||||
|
waiting: '🔵',
|
||||||
|
inactive: '⚪',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHANNEL_META_REFRESH_MS = 300000;
|
||||||
|
const STATUS_SNAPSHOT_MAX_AGE_MS = 60000;
|
||||||
|
|
||||||
|
let statusMessageId: string | null = null;
|
||||||
|
let cachedUsageContent = '';
|
||||||
|
let cachedClaudeUsageData: ClaudeUsageData | null = null;
|
||||||
|
let usageUpdateInProgress = false;
|
||||||
|
let usageApiBackoffUntil = 0;
|
||||||
|
let usageApi429Streak = 0;
|
||||||
|
let usageApiPollingDisabled = false;
|
||||||
|
let channelMetaCache = new Map<string, ChannelMeta>();
|
||||||
|
let channelMetaLastRefresh = 0;
|
||||||
|
|
||||||
|
function formatResetKST(value: string | number): string {
|
||||||
|
try {
|
||||||
|
const date =
|
||||||
|
typeof value === 'number' ? new Date(value * 1000) : new Date(value);
|
||||||
|
return date.toLocaleString('ko-KR', {
|
||||||
|
timeZone: 'Asia/Seoul',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findDiscordChannel(channels: Channel[]): Channel | undefined {
|
||||||
|
return channels.find(
|
||||||
|
(channel) => channel.name.startsWith('discord') && channel.isConnected(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function purgeDashboardChannel(
|
||||||
|
opts: Pick<UnifiedDashboardOptions, 'channels' | 'statusChannelId'>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!opts.statusChannelId) return;
|
||||||
|
|
||||||
|
const statusJid = `dc:${opts.statusChannelId}`;
|
||||||
|
const channel = opts.channels.find(
|
||||||
|
(item) =>
|
||||||
|
item.name.startsWith('discord') &&
|
||||||
|
item.isConnected() &&
|
||||||
|
item.purgeChannel,
|
||||||
|
);
|
||||||
|
if (channel?.purgeChannel) {
|
||||||
|
await channel.purgeChannel(statusJid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - channelMetaLastRefresh < CHANNEL_META_REFRESH_MS) return;
|
||||||
|
|
||||||
|
const channel = opts.channels.find(
|
||||||
|
(item) =>
|
||||||
|
item.name.startsWith('discord') && item.isConnected() && item.getChannelMeta,
|
||||||
|
);
|
||||||
|
if (!channel?.getChannelMeta) return;
|
||||||
|
|
||||||
|
const localJids = Object.keys(opts.registeredGroups()).filter((jid) =>
|
||||||
|
jid.startsWith('dc:'),
|
||||||
|
);
|
||||||
|
const snapshotJids = readStatusSnapshots(STATUS_SNAPSHOT_MAX_AGE_MS)
|
||||||
|
.flatMap((snapshot) => snapshot.entries.map((entry) => entry.jid))
|
||||||
|
.filter((jid) => jid.startsWith('dc:'));
|
||||||
|
const jids = [...new Set([...localJids, ...snapshotJids])];
|
||||||
|
|
||||||
|
try {
|
||||||
|
channelMetaCache = await channel.getChannelMeta(jids);
|
||||||
|
channelMetaLastRefresh = now;
|
||||||
|
|
||||||
|
for (const [jid, meta] of channelMetaCache) {
|
||||||
|
if (!meta.name) continue;
|
||||||
|
const group = opts.registeredGroups()[jid];
|
||||||
|
if (!group || group.name === meta.name) continue;
|
||||||
|
logger.info(
|
||||||
|
{ jid, oldName: group.name, newName: meta.name },
|
||||||
|
'Syncing group name to Discord channel name',
|
||||||
|
);
|
||||||
|
updateRegisteredGroupName(jid, meta.name);
|
||||||
|
opts.onGroupNameSynced?.(jid, meta.name);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug({ err }, 'Failed to refresh channel metadata');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAgentDisplayName(agentType: 'claude-code' | 'codex'): string {
|
||||||
|
return agentType === 'codex' ? '코덱스' : '클코';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRoomName(
|
||||||
|
jid: string,
|
||||||
|
meta: ChannelMeta | undefined,
|
||||||
|
fallbackName: string | undefined,
|
||||||
|
chatName: string | undefined,
|
||||||
|
): string {
|
||||||
|
const base =
|
||||||
|
meta?.name ||
|
||||||
|
(chatName && chatName !== jid ? chatName : undefined) ||
|
||||||
|
(fallbackName && fallbackName !== jid ? fallbackName : undefined) ||
|
||||||
|
jid;
|
||||||
|
|
||||||
|
if (jid.startsWith('dc:') && base !== jid && !base.startsWith('#')) {
|
||||||
|
return `#${base}`;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeLocalStatusSnapshot(opts: UnifiedDashboardOptions): void {
|
||||||
|
const groups = opts.registeredGroups();
|
||||||
|
const statuses = opts.queue.getStatuses(Object.keys(groups));
|
||||||
|
|
||||||
|
writeStatusSnapshot({
|
||||||
|
agentType: opts.serviceAgentType,
|
||||||
|
assistantName: opts.assistantName,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
entries: statuses
|
||||||
|
.map((status) => {
|
||||||
|
const group = groups[status.jid];
|
||||||
|
if (!group) return null;
|
||||||
|
return {
|
||||||
|
jid: status.jid,
|
||||||
|
name: group.name,
|
||||||
|
folder: group.folder,
|
||||||
|
agentType: (group.agentType || opts.serviceAgentType) as
|
||||||
|
| 'claude-code'
|
||||||
|
| 'codex',
|
||||||
|
status: status.status,
|
||||||
|
elapsedMs: status.elapsedMs,
|
||||||
|
pendingMessages: status.pendingMessages,
|
||||||
|
pendingTasks: status.pendingTasks,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean) as Array<{
|
||||||
|
jid: string;
|
||||||
|
name: string;
|
||||||
|
folder: string;
|
||||||
|
agentType: 'claude-code' | 'codex';
|
||||||
|
status: 'processing' | 'waiting' | 'inactive';
|
||||||
|
elapsedMs: number | null;
|
||||||
|
pendingMessages: boolean;
|
||||||
|
pendingTasks: number;
|
||||||
|
}>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStatusContent(): string {
|
||||||
|
if (!STATUS_SHOW_ROOMS) return '';
|
||||||
|
|
||||||
|
const snapshots = readStatusSnapshots(STATUS_SNAPSHOT_MAX_AGE_MS);
|
||||||
|
const chatNameByJid = new Map(
|
||||||
|
getAllChats().map((chat) => [chat.jid, chat.name]),
|
||||||
|
);
|
||||||
|
|
||||||
|
interface RoomEntry {
|
||||||
|
agentType: 'claude-code' | 'codex';
|
||||||
|
status: 'processing' | 'waiting' | 'inactive';
|
||||||
|
elapsedMs: number | null;
|
||||||
|
pendingMessages: boolean;
|
||||||
|
pendingTasks: number;
|
||||||
|
name: string;
|
||||||
|
meta: ChannelMeta | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byJid = new Map<string, RoomEntry[]>();
|
||||||
|
|
||||||
|
for (const snapshot of snapshots) {
|
||||||
|
const agentType = snapshot.agentType as 'claude-code' | 'codex';
|
||||||
|
for (const entry of snapshot.entries) {
|
||||||
|
const existing = byJid.get(entry.jid) || [];
|
||||||
|
existing.push({
|
||||||
|
agentType,
|
||||||
|
status: entry.status,
|
||||||
|
elapsedMs: entry.elapsedMs,
|
||||||
|
pendingMessages: entry.pendingMessages,
|
||||||
|
pendingTasks: entry.pendingTasks,
|
||||||
|
name: entry.name,
|
||||||
|
meta: channelMetaCache.get(entry.jid),
|
||||||
|
});
|
||||||
|
byJid.set(entry.jid, existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoomInfo {
|
||||||
|
name: string;
|
||||||
|
meta: ChannelMeta | undefined;
|
||||||
|
agents: RoomEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryMap = new Map<string, RoomInfo[]>();
|
||||||
|
let totalActive = 0;
|
||||||
|
let totalRooms = 0;
|
||||||
|
|
||||||
|
for (const [jid, agents] of byJid) {
|
||||||
|
const meta = agents[0]?.meta;
|
||||||
|
const category = meta?.category || '기타';
|
||||||
|
if (!categoryMap.has(category)) {
|
||||||
|
categoryMap.set(category, []);
|
||||||
|
}
|
||||||
|
categoryMap.get(category)!.push({
|
||||||
|
name: formatRoomName(
|
||||||
|
jid,
|
||||||
|
meta,
|
||||||
|
agents.find((agent) => agent.name && agent.name !== jid)?.name,
|
||||||
|
chatNameByJid.get(jid),
|
||||||
|
),
|
||||||
|
meta,
|
||||||
|
agents,
|
||||||
|
});
|
||||||
|
totalRooms++;
|
||||||
|
if (agents.some((agent) => agent.status === 'processing')) {
|
||||||
|
totalActive++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedCategories = [...categoryMap.entries()].sort((a, b) => {
|
||||||
|
const posA = a[1][0]?.meta?.categoryPosition ?? 999;
|
||||||
|
const posB = b[1][0]?.meta?.categoryPosition ?? 999;
|
||||||
|
return posA - posB;
|
||||||
|
});
|
||||||
|
|
||||||
|
const roomLines: DashboardRoomLine[] = [];
|
||||||
|
for (const [categoryName, rooms] of sortedCategories) {
|
||||||
|
rooms.sort((a, b) => (a.meta?.position ?? 999) - (b.meta?.position ?? 999));
|
||||||
|
for (const room of rooms) {
|
||||||
|
room.agents.sort((a, b) =>
|
||||||
|
a.agentType === b.agentType ? 0 : a.agentType === 'claude-code' ? -1 : 1,
|
||||||
|
);
|
||||||
|
const agentParts = room.agents.map((agent) => {
|
||||||
|
const icon = STATUS_ICONS[agent.status] || '⚪';
|
||||||
|
const label = formatDashboardStatusLabel({
|
||||||
|
status: agent.status,
|
||||||
|
elapsedMs: agent.elapsedMs,
|
||||||
|
pendingTasks: agent.pendingTasks,
|
||||||
|
});
|
||||||
|
const tag = getAgentDisplayName(agent.agentType);
|
||||||
|
return `${tag} ${icon} ${label}`;
|
||||||
|
});
|
||||||
|
roomLines.push({
|
||||||
|
category: categoryName,
|
||||||
|
categoryPosition: room.meta?.categoryPosition ?? 999,
|
||||||
|
position: room.meta?.position ?? 999,
|
||||||
|
line: ` **${room.name}** — ${agentParts.join(' | ')}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = `**📊 에이전트 상태** — 활성 ${totalActive} / ${totalRooms}`;
|
||||||
|
const sections = renderCategorizedRoomSections({
|
||||||
|
lines: roomLines,
|
||||||
|
showCategoryHeaders: channelMetaCache.size > 0,
|
||||||
|
});
|
||||||
|
return `${header}\n\n${sections}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function 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> {
|
||||||
|
const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin', 'codex');
|
||||||
|
const codexBin = fs.existsSync(npmGlobalBin) ? npmGlobalBin : 'codex';
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let done = false;
|
||||||
|
let proc: ChildProcess | null = null;
|
||||||
|
const finish = (value: CodexRateLimit[] | null) => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (proc) {
|
||||||
|
try {
|
||||||
|
proc.kill();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(() => finish(null), 20_000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
proc = spawn(codexBin, ['app-server'], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
env: {
|
||||||
|
...(process.env as Record<string, string>),
|
||||||
|
PATH: [
|
||||||
|
path.dirname(process.execPath),
|
||||||
|
path.join(os.homedir(), '.npm-global', 'bin'),
|
||||||
|
process.env.PATH || '',
|
||||||
|
].join(':'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!proc.stdout || !proc.stdin) {
|
||||||
|
finish(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
proc.on('error', () => finish(null));
|
||||||
|
proc.on('close', () => finish(null));
|
||||||
|
|
||||||
|
let buffer = '';
|
||||||
|
proc.stdout.on('data', (chunk: Buffer) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(line);
|
||||||
|
if (message.id === 1) {
|
||||||
|
proc!.stdin!.write(
|
||||||
|
JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 2,
|
||||||
|
method: 'account/rateLimits/read',
|
||||||
|
params: {},
|
||||||
|
}) + '\n',
|
||||||
|
);
|
||||||
|
} else if (message.id === 2 && message.result) {
|
||||||
|
const byId = message.result.rateLimitsByLimitId;
|
||||||
|
finish(
|
||||||
|
byId && typeof byId === 'object'
|
||||||
|
? (Object.values(byId) as CodexRateLimit[])
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.stdin.write(
|
||||||
|
JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: 'initialize',
|
||||||
|
params: { clientInfo: { name: 'usage-monitor', version: '1.0' } },
|
||||||
|
}) + '\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const shouldFetchClaudeUsage = USAGE_DASHBOARD_ENABLED;
|
||||||
|
|
||||||
|
const [liveClaudeUsage, codexUsage] = await Promise.all([
|
||||||
|
shouldFetchClaudeUsage && !hasActiveClaudeWork
|
||||||
|
? fetchClaudeUsage()
|
||||||
|
: Promise.resolve(null),
|
||||||
|
fetchCodexUsage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const claudeUsage = shouldFetchClaudeUsage
|
||||||
|
? liveClaudeUsage || cachedClaudeUsageData
|
||||||
|
: null;
|
||||||
|
const claudeUsageIsCached =
|
||||||
|
shouldFetchClaudeUsage && !liveClaudeUsage && !!cachedClaudeUsageData;
|
||||||
|
if (shouldFetchClaudeUsage && liveClaudeUsage) {
|
||||||
|
cachedClaudeUsageData = liveClaudeUsage;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = ['📊 *사용량*'];
|
||||||
|
const bar = (pct: number) => {
|
||||||
|
const filled = Math.round(pct / 10);
|
||||||
|
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 (codexUsage && Array.isArray(codexUsage)) {
|
||||||
|
const relevant = codexUsage.filter(
|
||||||
|
(limit) =>
|
||||||
|
limit.primary.usedPercent > 0 || limit.secondary.usedPercent > 0,
|
||||||
|
);
|
||||||
|
const display = relevant.length > 0 ? relevant : codexUsage.slice(0, 1);
|
||||||
|
for (const limit of display) {
|
||||||
|
rows.push({
|
||||||
|
name: 'Codex',
|
||||||
|
h5pct: Math.round(limit.primary.usedPercent),
|
||||||
|
h5reset: formatResetKST(limit.primary.resetsAt),
|
||||||
|
d7pct: Math.round(limit.secondary.usedPercent),
|
||||||
|
d7reset: formatResetKST(limit.secondary.resetsAt),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
lines.push('```');
|
||||||
|
lines.push(' 5-Hour 7-Day');
|
||||||
|
for (const row of rows) {
|
||||||
|
const h5 =
|
||||||
|
row.h5pct >= 0
|
||||||
|
? `${bar(row.h5pct)} ${String(row.h5pct).padStart(3)}%`
|
||||||
|
: ' — ';
|
||||||
|
const d7 =
|
||||||
|
row.d7pct >= 0
|
||||||
|
? `${bar(row.d7pct)} ${String(row.d7pct).padStart(3)}%`
|
||||||
|
: ' — ';
|
||||||
|
lines.push(`${row.name.padEnd(8)}${h5} ${d7}`);
|
||||||
|
}
|
||||||
|
lines.push('```');
|
||||||
|
} else {
|
||||||
|
lines.push('_조회 불가_');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldFetchClaudeUsage && usageApiPollingDisabled) {
|
||||||
|
lines.push(
|
||||||
|
'_* Claude 사용량 조회는 반복된 429로 이번 프로세스에서 일시 중지_',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (claudeUsageIsCached) {
|
||||||
|
lines.push('_* Claude 사용량은 작업 중일 때는 캐시값 유지_');
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
lines.push('🖥️ *서버*');
|
||||||
|
|
||||||
|
const loadAvg = os.loadavg();
|
||||||
|
const cpuCount = os.cpus().length;
|
||||||
|
const cpuPct = Math.round((loadAvg[1] / cpuCount) * 100);
|
||||||
|
const totalMem = os.totalmem();
|
||||||
|
const usedMem = totalMem - os.freemem();
|
||||||
|
const memPct = Math.round((usedMem / totalMem) * 100);
|
||||||
|
const memUsedGB = (usedMem / 1073741824).toFixed(1);
|
||||||
|
const memTotalGB = (totalMem / 1073741824).toFixed(1);
|
||||||
|
|
||||||
|
let diskPct = 0;
|
||||||
|
let diskUsedGB = '?';
|
||||||
|
let diskTotalGB = '?';
|
||||||
|
try {
|
||||||
|
const df = execSync('df -B1 / | tail -1', {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: 5000,
|
||||||
|
}).trim();
|
||||||
|
const parts = df.split(/\s+/);
|
||||||
|
const diskUsed = parseInt(parts[2], 10);
|
||||||
|
const diskTotal = parseInt(parts[1], 10);
|
||||||
|
diskPct = Math.round((diskUsed / diskTotal) * 100);
|
||||||
|
diskUsedGB = (diskUsed / 1073741824).toFixed(0);
|
||||||
|
diskTotalGB = (diskTotal / 1073741824).toFixed(0);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('```');
|
||||||
|
lines.push(`${'CPU'.padEnd(8)}${bar(cpuPct)} ${String(cpuPct).padStart(3)}%`);
|
||||||
|
lines.push(
|
||||||
|
`${'Memory'.padEnd(8)}${bar(memPct)} ${String(memPct).padStart(3)}% ${memUsedGB}/${memTotalGB}GB`,
|
||||||
|
);
|
||||||
|
lines.push(
|
||||||
|
`${'Disk'.padEnd(8)}${bar(diskPct)} ${String(diskPct).padStart(3)}% ${diskUsedGB}/${diskTotalGB}GB`,
|
||||||
|
);
|
||||||
|
lines.push(`${'Uptime'.padEnd(8)}${formatElapsed(os.uptime() * 1000)}`);
|
||||||
|
lines.push('```');
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUnifiedDashboardContent(): string {
|
||||||
|
const sections: string[] = [];
|
||||||
|
if (STATUS_SHOW_ROOMS) {
|
||||||
|
sections.push(buildStatusContent());
|
||||||
|
}
|
||||||
|
if (cachedUsageContent) {
|
||||||
|
sections.push(cachedUsageContent);
|
||||||
|
}
|
||||||
|
return composeDashboardContent(sections);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshUsageCache(): Promise<void> {
|
||||||
|
if (usageUpdateInProgress) return;
|
||||||
|
usageUpdateInProgress = true;
|
||||||
|
try {
|
||||||
|
cachedUsageContent = await buildUsageContent();
|
||||||
|
} catch {
|
||||||
|
/* keep previous cache */
|
||||||
|
} finally {
|
||||||
|
usageUpdateInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startUnifiedDashboard(
|
||||||
|
opts: UnifiedDashboardOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!opts.statusChannelId) return;
|
||||||
|
|
||||||
|
const isRenderer = opts.serviceAgentType === 'claude-code';
|
||||||
|
const statusJid = `dc:${opts.statusChannelId}`;
|
||||||
|
|
||||||
|
if (isRenderer && opts.purgeOnStart) {
|
||||||
|
await purgeDashboardChannel(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRenderer) {
|
||||||
|
await refreshUsageCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateStatus = async () => {
|
||||||
|
writeLocalStatusSnapshot(opts);
|
||||||
|
if (!isRenderer) return;
|
||||||
|
|
||||||
|
const channel = findDiscordChannel(opts.channels);
|
||||||
|
if (!channel) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await refreshChannelMeta(opts);
|
||||||
|
const content = buildUnifiedDashboardContent();
|
||||||
|
if (!content) {
|
||||||
|
statusMessageId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusMessageId && channel.editMessage) {
|
||||||
|
await channel.editMessage(statusJid, statusMessageId, content);
|
||||||
|
} else if (channel.sendAndTrack) {
|
||||||
|
const id = await channel.sendAndTrack(statusJid, content);
|
||||||
|
if (id) statusMessageId = id;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug({ err }, 'Dashboard update failed');
|
||||||
|
statusMessageId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setInterval(updateStatus, opts.statusUpdateInterval);
|
||||||
|
await updateStatus();
|
||||||
|
|
||||||
|
if (isRenderer) {
|
||||||
|
setInterval(refreshUsageCache, opts.usageUpdateInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
channelId: opts.statusChannelId,
|
||||||
|
isRenderer,
|
||||||
|
agentType: opts.serviceAgentType,
|
||||||
|
},
|
||||||
|
isRenderer
|
||||||
|
? 'Unified dashboard started'
|
||||||
|
: 'Status snapshot updater started',
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user