const { Plugins, Actions, log } = require('./utils/plugin'); const fs = require('fs'); const path = require('path'); const os = require('os'); const plugin = new Plugins('codex-monitor'); const timers = {}; const sessionOffsets = {}; const healthOffsets = {}; // ─── 直接调试日志 ────────────────────────────────────── const DEBUG_LOG = path.join(__dirname, '..', 'debug.log'); function dlog(msg) { try { fs.appendFileSync(DEBUG_LOG, '[' + new Date().toISOString() + '] ' + msg + '\n'); } catch (_) {} } dlog('=== Plugin started v6 ==='); // ─── 路径配置 ─────────────────────────────────────────── const HOME = os.homedir(); const HEALTH_MARKER = 'app_server_connection.state_changed'; const DEFAULT_POLL = 1000; const DONE_CUE_MS = 800; const COOLDOWN_MS = 300; const IDLE_TIMEOUT_MS = 3000; // ─── 简单递归文件查找 ────────────────────────────────── function findFiles(rootDir, suffix, maxDepth) { var results = []; maxDepth = maxDepth || 16; if (!fs.existsSync(rootDir)) return results; (function walk(dir, depth) { if (depth > maxDepth) return; var names; try { names = fs.readdirSync(dir); } catch (_) { return; } for (var i = 0; i < names.length; i++) { var name = names[i]; var full = path.join(dir, name); var st; try { st = fs.statSync(full); } catch (_) { continue; } if (st.isDirectory()) walk(full, depth + 1); else if (st.isFile() && name.endsWith(suffix)) results.push(full); } })(rootDir, 0); return results; } // ─── 状态映射 ─────────────────────────────────────────── var STATE = { IDLE: 'idle', PROMPT: 'prompt', THINK: 'thinking', WORK: 'working', TYPING: 'typing', DONE: 'done', ERROR: 'error', WAITING: 'waiting', RECONNECTING: 'reconnecting', RECOVERED: 'recovered' }; var STATUS_DISPLAY = {}; STATUS_DISPLAY['idle'] = { title: 'Ready', fill: '#22c55e', blink: false, breathe: false }; STATUS_DISPLAY['prompt'] = { title: 'Prompt', fill: '#3b82f6', blink: false, breathe: false }; STATUS_DISPLAY['thinking'] = { title: 'Thinking', fill: '#f59e0b', blink: false, breathe: true }; STATUS_DISPLAY['working'] = { title: 'Working', fill: '#f59e0b', blink: true, breathe: false }; STATUS_DISPLAY['typing'] = { title: 'Typing', fill: '#f59e0b', blink: false, breathe: true }; STATUS_DISPLAY['done'] = { title: 'Done', fill: '#22c55e', blink: true, breathe: false }; STATUS_DISPLAY['error'] = { title: 'Error', fill: '#ef4444', blink: false, breathe: false }; STATUS_DISPLAY['waiting'] = { title: 'Waiting', fill: '#ef4444', blink: true, breathe: false }; STATUS_DISPLAY['reconnecting'] = { title: 'Reconnect', fill: '#ef4444', blink: true, breathe: false }; STATUS_DISPLAY['recovered'] = { title: 'Online', fill: '#22c55e', blink: false, breathe: false }; // 优先级: ERROR > WORK > TYPING > THINK > PROMPT > DONE > IDLE var STATE_PRIORITY = ['error', 'waiting', 'reconnecting', 'working', 'typing', 'thinking', 'prompt', 'done', 'idle']; function makeSvg(fill, label, blink, breathe) { var anim = ''; if (blink) anim = ''; else if (breathe) anim = ''; return '' + anim + '' + label + ''; } function setDisplay(context, state) { var d = STATUS_DISPLAY[state] || STATUS_DISPLAY['idle']; plugin.setTitle(context, d.title); plugin.setImage(context, 'data:image/svg+xml;charset=utf-8,' + makeSvg(d.fill, d.title, d.blink, d.breathe)); } // ─── 状态管理 ────────────────────────────────────────── var currentCtx = null, currentState = 'idle', lastHwSet = 0, lastActivityTime = 0; var animationHandle = null; function animateTick() { if (!currentCtx) return; var d = STATUS_DISPLAY[currentState] || STATUS_DISPLAY['idle']; if (d.blink || d.breathe) { var now = Date.now(); var period = d.blink ? 800 : 2000; var phase = (now % period) / period; var alpha = d.blink ? (phase < 0.5 ? 1 : 0.2) : 0.4 + 0.6 * Math.sin(phase * Math.PI); var r = parseInt(d.fill.slice(1,3), 16), g = parseInt(d.fill.slice(3,5), 16), b = parseInt(d.fill.slice(5,7), 16); var color = 'rgb(' + Math.round(16+(r-16)*alpha) + ',' + Math.round(16+(g-16)*alpha) + ',' + Math.round(16+(b-16)*alpha) + ')'; var svg = '' + d.title + ''; plugin.setImage(currentCtx, 'data:image/svg+xml;charset=utf-8,' + svg); } } function setState(context, state) { if (state === currentState && context === currentCtx && Date.now() - lastHwSet < COOLDOWN_MS) return; if (state === 'done') { setDisplay(context, 'done'); currentState = 'done'; setTimeout(function () { if (currentState === 'done') { currentState = 'idle'; setDisplay(context, 'idle'); } }, DONE_CUE_MS); } else if (state === 'recovered') { currentState = 'idle'; setDisplay(context, 'idle'); } else { currentState = state; setDisplay(context, state); } lastHwSet = Date.now(); dlog('State: ' + state); } // ─── Session JSONL 解析 ──────────────────────────────── function normalizeEvent(obj) { var type = obj.type || ''; var pt = (obj.payload || {}).type || ''; var it = ((obj.payload || {}).item || {}).type || ''; if (type === 'event_msg') { if (pt === 'task_started') return 'thinking'; if (pt === 'task_complete') return 'done'; if (pt === 'turn_aborted') return 'error'; if (pt === 'agent_message') return 'typing'; if (pt === 'user_message') return 'prompt'; if (pt === 'web_search_end') return 'working'; if (pt === 'agent_reasoning') return 'thinking'; return null; } if (type === 'response_item') { var nt = pt || it; if (nt === 'function_call' || nt === 'custom_tool_call' || nt === 'function_call_output' || nt === 'custom_tool_call_output') return 'working'; if (nt === 'web_search_call') return 'working'; if (nt === 'reasoning') return 'thinking'; if (nt === 'message') return 'typing'; return null; } return null; } // ─── Session tails ───────────────────────────────────── function latestSessionFiles() { try { var dir = path.join(HOME, '.codex', 'sessions'); var files = findFiles(dir, '.jsonl'); files.sort(function (a, b) { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; }); return files.slice(0, 3); } catch (_) { return []; } } function tailSession(filePath, context) { var detected = false; try { var stat = fs.statSync(filePath); var off = sessionOffsets[filePath] || 0; if (stat.size < off) { sessionOffsets[filePath] = 0; return false; } if (stat.size === off) return false; var fd = fs.openSync(filePath, 'r'); var grow = Math.min(stat.size - off, 65536); var buf = Buffer.alloc(grow); fs.readSync(fd, buf, 0, grow, off); fs.closeSync(fd); sessionOffsets[filePath] = stat.size; var lines = buf.toString('utf-8').split('\n').filter(function (l) { return l.trim(); }); var bestPri = STATE_PRIORITY.length, bestState = null; for (var i = 0; i < lines.length; i++) { try { var obj = JSON.parse(lines[i]); var s = normalizeEvent(obj); if (s) { var pri = STATE_PRIORITY.indexOf(s); if (pri >= 0 && pri < bestPri) { bestPri = pri; bestState = s; } } } catch (_) {} } if (bestState) { dlog('Detected: ' + bestState + ' from ' + path.basename(filePath)); setState(context, bestState); detected = true; } } catch (_) { delete sessionOffsets[filePath]; } return detected; } // ─── Health monitoring ───────────────────────────────── var healthState = { reconnecting: false }; function parseHealthLine(line) { if (line.indexOf(HEALTH_MARKER) === -1) return null; var fieldRe = /([A-Za-z][A-Za-z0-9_]*)=("[^"]*"|\S+)/g; var fields = {}, m; while ((m = fieldRe.exec(line)) !== null) { var val = m[2]; if (val.charAt(0) === '"' && val.charAt(val.length - 1) === '"') val = val.slice(1, -1); fields[m[1]] = val; } var next = fields.next || ''; var prev = fields.previous || ''; var attempt = parseInt(fields.reconnectAttempt || '0', 10); var scheduled = (fields.reconnectTimerScheduled || '').toLowerCase() === 'true'; var err = fields.connectionError || ''; var cause = fields.cause || ''; if (next === 'connected' && healthState.reconnecting) { healthState.reconnecting = false; return 'recovered'; } if ((next === 'connecting' || next === 'disconnected') && (attempt > 0 || scheduled || (err && err.toLowerCase() !== 'null') || (prev === 'connected' && cause !== 'stop_process' && cause !== 'shutdown'))) { healthState.reconnecting = true; return 'reconnecting'; } return null; } function latestHealthLogs() { try { var dir = process.platform === 'win32' ? path.join(HOME, 'AppData', 'Local', 'Codex', 'Logs') : path.join(HOME, 'Library', 'Logs', 'com.openai.codex'); var files = findFiles(dir, '.log'); files.sort(function (a, b) { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; }); return files.slice(0, 3); } catch (_) { return []; } } function tailHealth(filePath, context) { try { var stat = fs.statSync(filePath); var off = healthOffsets[filePath] || 0; if (stat.size < off) { healthOffsets[filePath] = 0; return; } if (stat.size === off) return; var fd = fs.openSync(filePath, 'r'); var grow = Math.min(stat.size - off, 65536); var buf = Buffer.alloc(grow); fs.readSync(fd, buf, 0, grow, off); fs.closeSync(fd); healthOffsets[filePath] = stat.size; var lines = buf.toString('utf-8').split('\n').filter(function (l) { return l.trim(); }); var lastHealth = null; for (var i = 0; i < lines.length; i++) { var s = parseHealthLine(lines[i]); if (s) lastHealth = s; } if (lastHealth) { dlog('Health: ' + lastHealth); setState(context, lastHealth); } } catch (_) { delete healthOffsets[filePath]; } } // ─── 主轮询 ──────────────────────────────────────────── var pollCount = 0; function poll(context, interval, showHealth) { pollCount++; if (pollCount % 10 === 1) dlog('Poll #' + pollCount); var detected = false; var files = latestSessionFiles(); for (var i = 0; i < files.length; i++) { if (tailSession(files[i], context)) detected = true; } if (detected) lastActivityTime = Date.now(); // Idle timeout: return to IDLE if nothing happened for IDLE_TIMEOUT_MS if (currentState !== 'idle' && currentState !== 'done' && Date.now() - lastActivityTime > IDLE_TIMEOUT_MS) { dlog('Idle timeout - returning to IDLE'); setState(context, 'idle'); } if (showHealth) { var hfiles = latestHealthLogs(); for (var j = 0; j < hfiles.length; j++) tailHealth(hfiles[j], context); } } // ─── Action 定义 ─────────────────────────────────────── plugin.monitor = new Actions({ default: { pollInterval: DEFAULT_POLL, showHealth: true }, _willAppear: function (data) { var ctx = data.context; dlog('WillAppear: ' + ctx); currentCtx = ctx; currentState = 'idle'; lastHwSet = 0; lastActivityTime = Date.now(); setDisplay(ctx, 'idle'); var s = plugin.monitor.data[ctx]; var interval = s.pollInterval || DEFAULT_POLL; var showHealth = s.showHealth !== false; poll(ctx, interval, showHealth); timers[ctx] = setInterval(function () { poll(ctx, interval, showHealth); }, interval); if (!animationHandle) animationHandle = setInterval(animateTick, 200); }, _willDisappear: function (data) { var ctx = data.context; dlog('WillDisappear: ' + ctx); if (timers[ctx]) { clearInterval(timers[ctx]); delete timers[ctx]; } if (currentCtx === ctx) { currentCtx = null; currentState = 'idle'; } if (Object.keys(timers).length === 0 && animationHandle) { clearInterval(animationHandle); animationHandle = null; } }, _didReceiveSettings: function (data) { var ctx = data.context; if (timers[ctx]) clearInterval(timers[ctx]); var s = plugin.monitor.data[ctx]; var interval = s.pollInterval || DEFAULT_POLL; var showHealth = s.showHealth !== false; poll(ctx, interval, showHealth); timers[ctx] = setInterval(function () { poll(ctx, interval, showHealth); }, interval); }, keyUp: function (data) { var s = plugin.monitor.data[data.context]; poll(data.context, s.pollInterval || DEFAULT_POLL, s.showHealth !== false); }, sendToPlugin: function (data) { if (data.payload && data.payload.command === 'refresh') { var s = plugin.monitor.data[data.context]; poll(data.context, s.pollInterval || DEFAULT_POLL, s.showHealth !== false); } } });