245 lines
11 KiB
JavaScript
245 lines
11 KiB
JavaScript
const { Plugins, Actions, log } = require('./utils/plugin');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
|
|
const plugin = new Plugins('ai-monitor');
|
|
const timers = {};
|
|
const sessionOffsets = {};
|
|
const DEBUG_LOG = path.join(__dirname, '..', 'debug.log');
|
|
function dlog(msg) { try { fs.appendFileSync(DEBUG_LOG, '[' + new Date().toISOString() + '] ' + msg + '\n'); } catch (_) {} }
|
|
dlog('=== AI Monitor v7 multi-agent ===');
|
|
|
|
const HOME = os.homedir();
|
|
const DEFAULT_POLL = 1000;
|
|
const DONE_CUE_MS = 800;
|
|
const COOLDOWN_MS = 300;
|
|
const IDLE_TIMEOUT_MS = 3000;
|
|
|
|
var AGENTS = {
|
|
codex: {
|
|
label: 'Codex',
|
|
scanDir: path.join(HOME, '.codex', 'sessions'),
|
|
fileSuffix: '.jsonl',
|
|
normalize: function(obj) {
|
|
var t = obj.type || '';
|
|
var pt = (obj.payload || {}).type || '';
|
|
var it = ((obj.payload || {}).item || {}).type || '';
|
|
if (t === '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 (t === '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;
|
|
}
|
|
},
|
|
claude: {
|
|
label: 'Claude',
|
|
scanDir: path.join(HOME, '.claude', 'projects'),
|
|
fileSuffix: '.jsonl',
|
|
normalize: function(obj) {
|
|
var t = obj.type || '';
|
|
if (t === 'user') return 'prompt';
|
|
if (t === 'assistant') {
|
|
var content = (obj.message || {}).content || [];
|
|
for (var i = 0; i < content.length; i++) {
|
|
var ct = content[i].type || '';
|
|
if (ct === 'tool_use') return 'working';
|
|
if (ct === 'thinking') return 'thinking';
|
|
}
|
|
for (var j = 0; j < content.length; j++) {
|
|
if ((content[j].type || '') === 'text') return 'typing';
|
|
}
|
|
return 'typing';
|
|
}
|
|
if (t === 'system') { if (obj.subtype === 'turn_duration') return 'done'; return null; }
|
|
if (t === 'error' || t === 'api_error') return 'error';
|
|
return null;
|
|
}
|
|
},
|
|
opencode: {
|
|
label: 'opencode',
|
|
scanDir: path.join(HOME, '.opencode', 'sessions'),
|
|
fileSuffix: '.jsonl',
|
|
normalize: function(obj) {
|
|
var t = obj.type || '';
|
|
if (t === 'user') return 'prompt';
|
|
if (t === 'assistant') return 'typing';
|
|
if (t === 'tool' || t === 'tool_call') return 'working';
|
|
if (t === 'thinking' || t === 'reasoning') return 'thinking';
|
|
if (t === 'done' || t === 'complete') return 'done';
|
|
if (t === 'error') return 'error';
|
|
return null;
|
|
}
|
|
}
|
|
};
|
|
|
|
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 };
|
|
|
|
var STATE_PRIORITY = ['error','waiting','reconnecting','working','typing','thinking','prompt','done','idle'];
|
|
var currentCtx = null, currentState = 'idle', lastHwSet = 0, lastActivityTime = 0;
|
|
var animationHandle = null, currentAgent = null;
|
|
|
|
function makeSvg(fill, label, blink, breathe) {
|
|
var anim = '';
|
|
if (blink) anim = '<animate attributeName="opacity" values="1;0.2;1" dur="0.8s" repeatCount="indefinite"/>';
|
|
else if (breathe) anim = '<animate attributeName="opacity" values="1;0.4;1" dur="2s" repeatCount="indefinite"/>';
|
|
return '<svg width="144" height="144" xmlns="http://www.w3.org/2000/svg"><rect width="144" height="144" rx="20" fill="#1a1a2e"/><circle cx="72" cy="58" r="32" fill="' + fill + '">' + anim + '</circle><text x="72" y="118" font-family="Arial" font-weight="bold" font-size="13" fill="white" text-anchor="middle">' + label + '</text></svg>';
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
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 = '<svg width="144" height="144" xmlns="http://www.w3.org/2000/svg"><rect width="144" height="144" rx="20" fill="#1a1a2e"/><circle cx="72" cy="58" r="32" fill="' + color + '"/><text x="72" y="118" font-family="Arial" font-weight="bold" font-size="13" fill="white" text-anchor="middle">' + d.title + '</text></svg>';
|
|
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 + ' [' + (currentAgent ? currentAgent.label : '?') + ']');
|
|
}
|
|
|
|
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 full = path.join(dir, names[i]);
|
|
var st; try { st = fs.statSync(full); } catch (_) { continue; }
|
|
if (st.isDirectory()) walk(full, depth + 1);
|
|
else if (st.isFile() && names[i].endsWith(suffix)) results.push(full);
|
|
}
|
|
})(rootDir, 0);
|
|
return results;
|
|
}
|
|
|
|
function latestFiles(rootDir, suffix, limit) {
|
|
try {
|
|
var files = findFiles(rootDir, suffix);
|
|
files.sort(function(a,b) { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; });
|
|
return files.slice(0, limit || 3);
|
|
} catch (_) { return []; }
|
|
}
|
|
|
|
function tailFile(filePath, agent, 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 = agent.normalize(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;
|
|
}
|
|
|
|
var pollCount = 0;
|
|
function poll(context, interval, agent) {
|
|
pollCount++;
|
|
if (pollCount % 10 === 1) dlog('Poll #' + pollCount + ' [' + (agent ? agent.label : '?') + ']');
|
|
if (!agent) return;
|
|
var detected = false;
|
|
var files = latestFiles(agent.scanDir, agent.fileSuffix);
|
|
for (var i = 0; i < files.length; i++) { if (tailFile(files[i], agent, context)) detected = true; }
|
|
if (detected) lastActivityTime = Date.now();
|
|
if (currentState !== 'idle' && currentState !== 'done' && Date.now() - lastActivityTime > IDLE_TIMEOUT_MS) {
|
|
dlog('Idle timeout - returning to IDLE'); setState(context, 'idle');
|
|
}
|
|
}
|
|
|
|
plugin.monitor = new Actions({
|
|
default: { pollInterval: DEFAULT_POLL, agent: 'codex' },
|
|
_willAppear: function(data) {
|
|
var ctx = data.context, s = plugin.monitor.data[ctx], agentKey = s.agent || 'codex', agent = AGENTS[agentKey];
|
|
if (!agent) { agent = AGENTS['codex']; dlog('Unknown agent: ' + agentKey + ', falling back to codex'); }
|
|
currentAgent = agent; dlog('WillAppear: ' + ctx + ' agent=' + agentKey);
|
|
currentCtx = ctx; currentState = 'idle'; lastHwSet = 0; lastActivityTime = Date.now();
|
|
setDisplay(ctx, 'idle');
|
|
var interval = s.pollInterval || DEFAULT_POLL;
|
|
poll(ctx, interval, agent);
|
|
timers[ctx] = setInterval(function() { poll(ctx, interval, agent); }, interval);
|
|
if (!animationHandle) animationHandle = setInterval(animateTick, 200);
|
|
},
|
|
_willDisappear: function(data) {
|
|
var ctx = data.context;
|
|
if (timers[ctx]) { clearInterval(timers[ctx]); delete timers[ctx]; }
|
|
if (currentCtx === ctx) { currentCtx = null; currentAgent = 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], agentKey = s.agent || 'codex', agent = AGENTS[agentKey];
|
|
if (!agent) agent = AGENTS['codex']; currentAgent = agent;
|
|
var interval = s.pollInterval || DEFAULT_POLL;
|
|
poll(ctx, interval, agent);
|
|
timers[ctx] = setInterval(function() { poll(ctx, interval, agent); }, interval);
|
|
},
|
|
keyUp: function(data) {
|
|
var s = plugin.monitor.data[data.context], agent = AGENTS[s.agent || 'codex'] || AGENTS['codex'];
|
|
poll(data.context, s.pollInterval || DEFAULT_POLL, agent);
|
|
},
|
|
sendToPlugin: function(data) {
|
|
if (data.payload && data.payload.command === 'refresh') {
|
|
var s = plugin.monitor.data[data.context], agent = AGENTS[s.agent || 'codex'] || AGENTS['codex'];
|
|
poll(data.context, s.pollInterval || DEFAULT_POLL, agent);
|
|
}
|
|
}
|
|
}); |