Files
streamdock-ai-monitor/claude/com.streamdock.ai-monitor.claude.sdPlugin/plugin/index.js
T

153 lines
7.0 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-claude');
const timers = {};
const sessionOffsets = {};
const HOME = os.homedir();
const SCAN_DIR = path.join(HOME, '.claude', 'projects');
const POLL_INTERVAL = 1000;
const IDLE_TIMEOUT_MS = 3000;
// Load logo
var logoUri = '';
try {
var logoPath = path.join(__dirname, '..', 'static', 'img', 'claude-icon.svg');
logoUri = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(fs.readFileSync(logoPath, 'utf-8'));
} catch (_) {}
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 currentCtx = null, currentState = 'idle', lastActivityTime = 0;
var animationHandle = 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="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'];
plugin.setTitle(ctx, d.title);
if (state === 'idle' && logoUri) plugin.setImage(ctx, logoUri);
else plugin.setImage(ctx, 'data:image/svg+xml;charset=utf-8,' + makeSvg(d.fill, d.title, d.blink, d.breathe));
}
function animateTick() {
if (!currentCtx) return;
var d = STATUS[currentState] || STATUS['idle'];
if (d.blink || d.breathe) {
var now = Date.now(), period = d.blink ? 800 : 2000, 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="none"/><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(ctx, state) {
if (state === currentState) return;
if (state === 'done') { setDisplay(ctx, 'done'); currentState = 'done'; setTimeout(function() { if (currentState === 'done') { currentState = 'idle'; setDisplay(ctx, 'idle'); } }, 800); }
else { currentState = state; setDisplay(ctx, state); }
}
function normalize(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;
}
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) {
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 = PRIORITY.length, bestState = null;
for (var i = 0; i < lines.length; i++) {
try { var obj = JSON.parse(lines[i]); var s = 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 sessionOffsets[filePath]; }
return detected;
}
function poll(ctx) {
var detected = false;
var files = findFiles(SCAN_DIR, '.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)) detected = true; }
if (detected) lastActivityTime = Date.now();
if (currentState !== 'idle' && currentState !== 'done' && Date.now() - lastActivityTime > IDLE_TIMEOUT_MS) { setState(ctx, 'idle'); }
}
plugin.claude = new Actions({
default: { pollInterval: POLL_INTERVAL },
_willAppear: function(data) {
var ctx = data.context;
currentCtx = ctx; currentState = 'idle'; lastActivityTime = Date.now();
setDisplay(ctx, 'idle');
poll(ctx);
timers[ctx] = setInterval(function() { poll(ctx); }, POLL_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; currentState = 'idle'; }
if (Object.keys(timers).length === 0 && animationHandle) { clearInterval(animationHandle); animationHandle = null; }
},
keyUp: function(data) { poll(data.context); }
});