204 lines
8.5 KiB
JavaScript
204 lines
8.5 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 HOME = os.homedir();
|
|
const POLL_INTERVAL = 1000;
|
|
const IDLE_TIMEOUT_MS = 3000;
|
|
|
|
// Agent configs
|
|
var AGENTS = {
|
|
codex: {
|
|
label: 'Codex',
|
|
scanDir: path.join(HOME, '.codex', 'sessions'),
|
|
logoFile: 'codex-icon.svg',
|
|
normalize: function(obj) {
|
|
var t = obj.type || '', pt = (obj.payload || {}).type || '', 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'),
|
|
logoFile: 'claude-icon.svg',
|
|
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; }
|
|
return null;
|
|
}
|
|
},
|
|
opencode: {
|
|
label: 'opencode',
|
|
scanDir: path.join(HOME, '.opencode', 'sessions'),
|
|
logoFile: 'opencode-icon.svg',
|
|
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;
|
|
}
|
|
}
|
|
};
|
|
|
|
// Load logos as data URIs
|
|
var LOGOS = {};
|
|
function loadLogo(filename) {
|
|
try { return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(fs.readFileSync(path.join(__dirname, '..', 'static', 'img', filename), 'utf-8')); }
|
|
catch (_) { return ''; }
|
|
}
|
|
Object.keys(AGENTS).forEach(function(k) { LOGOS[k] = loadLogo(AGENTS[k].logoFile); });
|
|
|
|
// Shared display machinery
|
|
var STATUS = {
|
|
idle: { title: 'Ready', fill: '#22c55e', blink: false, breathe: false },
|
|
prompt: { title: 'Prompt', fill: '#3b82f6', blink: false, breathe: false },
|
|
thinking: { title: 'Thinking', fill: '#f59e0b', blink: false, breathe: true },
|
|
working: { title: 'Working', fill: '#f59e0b', blink: true, breathe: false },
|
|
typing: { title: 'Typing', fill: '#f59e0b', blink: false, breathe: true },
|
|
done: { title: 'Done', fill: '#22c55e', blink: true, breathe: false },
|
|
error: { title: 'Error', fill: '#ef4444', blink: false, breathe: false },
|
|
};
|
|
var PRIORITY = ['error','working','typing','thinking','prompt','done','idle'];
|
|
var states = {}; // per-context state: { agent, currentState, lastActivity, offsets, timer, logoUri }
|
|
|
|
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="none"/><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(ctx, state) {
|
|
var d = STATUS[state] || STATUS['idle'];
|
|
var ss = states[ctx];
|
|
plugin.setTitle(ctx, d.title);
|
|
if (state === 'idle' && ss && ss.logoUri) plugin.setImage(ctx, ss.logoUri);
|
|
else plugin.setImage(ctx, 'data:image/svg+xml;charset=utf-8,' + makeSvg(d.fill, d.title, d.blink, d.breathe));
|
|
}
|
|
|
|
function setState(ctx, state) {
|
|
var ss = states[ctx]; if (!ss) return;
|
|
if (state === ss.currentState) return;
|
|
if (state === 'done') { setDisplay(ctx, 'done'); ss.currentState = 'done'; setTimeout(function() { if (states[ctx] && states[ctx].currentState === 'done') { states[ctx].currentState = 'idle'; setDisplay(ctx, 'idle'); } }, 800); }
|
|
else { ss.currentState = state; setDisplay(ctx, state); }
|
|
}
|
|
|
|
function findFiles(rootDir, suffix) {
|
|
var results = [];
|
|
if (!fs.existsSync(rootDir)) return results;
|
|
(function walk(dir, depth) {
|
|
if (depth > 16) 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 tailFile(filePath, ctx, agent) {
|
|
var ss = states[ctx]; if (!ss) return false;
|
|
var detected = false;
|
|
try {
|
|
var stat = fs.statSync(filePath);
|
|
var off = ss.offsets[filePath] || 0;
|
|
if (stat.size < off) { ss.offsets[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);
|
|
ss.offsets[filePath] = stat.size;
|
|
var lines = buf.toString('utf-8').split('\n').filter(function(l) { return l.trim(); });
|
|
var bestPri = 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 = PRIORITY.indexOf(s); if (pri >= 0 && pri < bestPri) { bestPri = pri; bestState = s; } } } catch (_) {}
|
|
}
|
|
if (bestState) { setState(ctx, bestState); detected = true; }
|
|
} catch (_) { delete ss.offsets[filePath]; }
|
|
return detected;
|
|
}
|
|
|
|
function poll(ctx) {
|
|
var ss = states[ctx]; if (!ss || !ss.agent) return;
|
|
var detected = false;
|
|
var files = findFiles(ss.agent.scanDir, '.jsonl');
|
|
files.sort(function(a,b) { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; });
|
|
for (var i = 0; i < Math.min(files.length, 3); i++) { if (tailFile(files[i], ctx, ss.agent)) detected = true; }
|
|
if (detected) ss.lastActivity = Date.now();
|
|
if (ss.currentState !== 'idle' && ss.currentState !== 'done' && Date.now() - ss.lastActivity > IDLE_TIMEOUT_MS) { setState(ctx, 'idle'); }
|
|
}
|
|
|
|
function makeActionFn(agentKey) {
|
|
var agent = AGENTS[agentKey];
|
|
return {
|
|
default: { pollInterval: POLL_INTERVAL },
|
|
_willAppear: function(data) {
|
|
var ctx = data.context;
|
|
var logoUri = LOGOS[agentKey] || '';
|
|
states[ctx] = { agent: agent, currentState: 'idle', lastActivity: Date.now(), offsets: {}, timer: null, logoUri: logoUri };
|
|
setDisplay(ctx, 'idle');
|
|
poll(ctx);
|
|
states[ctx].timer = setInterval(function() { poll(ctx); }, POLL_INTERVAL);
|
|
},
|
|
_willDisappear: function(data) {
|
|
var ctx = data.context;
|
|
if (states[ctx] && states[ctx].timer) { clearInterval(states[ctx].timer); }
|
|
delete states[ctx];
|
|
},
|
|
_didReceiveSettings: function(data) {
|
|
var ctx = data.context;
|
|
var ss = states[ctx]; if (!ss) return;
|
|
if (ss.timer) clearInterval(ss.timer);
|
|
var settings = plugin[agentKey].data[ctx];
|
|
var interval = (settings && settings.pollInterval) || POLL_INTERVAL;
|
|
ss.timer = setInterval(function() { poll(ctx); }, interval);
|
|
},
|
|
keyUp: function(data) { poll(data.context); },
|
|
};
|
|
}
|
|
|
|
// Register each agent as a separate action
|
|
plugin.codex = new Actions(makeActionFn('codex'));
|
|
plugin.claude = new Actions(makeActionFn('claude'));
|
|
plugin.opencode = new Actions(makeActionFn('opencode'));
|