first commit: StreamDock AI Monitor - Codex Desktop status display

This commit is contained in:
2026-06-18 14:28:09 +08:00
commit 6a75b46453
22 changed files with 2881 additions and 0 deletions
@@ -0,0 +1,4 @@
plugin/node_modules/**
plugin/log/
plugin/build/
plugin/data/
@@ -0,0 +1,16 @@
{
"Name": "Codex Monitor",
"Description": "Monitor Codex Desktop AI workflow status on N4 buttons in real-time",
"PollInterval": "Poll Interval (ms)",
"ShowHealth": "Show Connection Health",
"StatusIdle": "Ready",
"StatusPrompt": "Prompt",
"StatusThinking": "Thinking",
"StatusWorking": "Working",
"StatusTyping": "Typing",
"StatusDone": "Done",
"StatusError": "Error",
"StatusWaiting": "Waiting",
"StatusReconnecting": "Reconnecting",
"StatusRecovered": "Recovered"
}
@@ -0,0 +1,43 @@
{
"Actions": [
{
"Icon": "static/img/codex-idle.svg",
"Name": "Codex Monitor",
"DisableAutomaticStates": true,
"States": [
{
"Image": "static/img/codex-idle.svg",
"TitleAlignment": "center",
"FontSize": "11"
}
],
"Settings": {
"pollInterval": 1000,
"showHealth": true
},
"Controllers": ["Keypad", "Information"],
"UserTitleEnabled": true,
"SupportedInMultiActions": true,
"Tooltip": "Codex Desktop 工作流状态监控",
"UUID": "com.streamdock.codex.monitor",
"PropertyInspectorPath": "propertyInspector/codex-monitor/index.html"
}
],
"SDKVersion": 1,
"Author": "StreamDock",
"Name": "Codex Monitor",
"Icon": "static/img/codex-icon.svg",
"Category": "Codex",
"CategoryIcon": "static/img/codex-icon.svg",
"CodePathWin": "plugin/index.js",
"CodePathMac": "plugin/index.js",
"Description": "监控 Codex Desktop AI 工作流状态,在 N4 按钮上实时显示运行/思考/完成/错误等状态",
"Version": "1.0.0",
"URL": "https://github.com/openai/codex",
"OS": [
{ "Platform": "windows", "MinimumVersion": "7" },
{ "Platform": "mac", "MinimumVersion": "10.11" }
],
"Software": { "MinimumVersion": "3.10.188.226" },
"Nodejs": { "Version": "20" }
}
@@ -0,0 +1,47 @@
const path = require('path');
const fs = require('fs-extra');
console.log('开始执行自动化构建...');
const currentDir = __dirname;
// 获取父文件夹的路径
const parentDir = path.join(currentDir, '..');
// 获取父文件夹的名称
const PluginName = path.basename(parentDir);
const PluginPath = path.join(process.env.APPDATA, 'HotSpot/StreamDock/plugins', PluginName);
try {
// 删除旧的插件目录
fs.removeSync(PluginPath);
// 确保目标目录存在
fs.ensureDirSync(path.dirname(PluginPath));
// 复制当前目录到目标路径,排除 node_modules
fs.copySync(path.resolve(__dirname, '..'), PluginPath, {
filter: (src) => {
const relativePath = path.relative(path.resolve(__dirname, '..'), src);
// 排除 'node_modules' 和 '.git' 目录及其子文件
return !relativePath.startsWith('plugin\\node_modules')
&&!relativePath.startsWith('plugin\\index.js')
&&!relativePath.startsWith('plugin\\package.json')
&&!relativePath.startsWith('plugin\\package-lock.json')
&&!relativePath.startsWith('plugin\\pnpm-lock.yaml')
&&!relativePath.startsWith('plugin\\yarn.lock')
&&!relativePath.startsWith('plugin\\build')
&&!relativePath.startsWith('plugin\\log')
&&!relativePath.startsWith('.git')
&&!relativePath.startsWith('.vscode');
}
});
fs.copySync( path.join(__dirname, "build"), path.join(PluginPath,'plugin'))
console.log(`插件 "${PluginName}" 已成功复制到 "${PluginPath}"`);
console.log('构建成功-------------');
} catch (err) {
console.error(`复制出错 "${PluginName}":`, err);
}
@@ -0,0 +1,320 @@
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 = '<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));
}
// ─── 状态管理 ──────────────────────────────────────────
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 = '<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);
}
// ─── 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);
}
}
});
@@ -0,0 +1,186 @@
{
"name": "codex-monitor",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codex-monitor",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"fs-extra": "^11.3.0",
"log4js": "^6.9.1",
"ws": "^8.14.2"
}
},
"node_modules/date-format": {
"version": "4.0.14",
"resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz",
"integrity": "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/flatted": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"license": "ISC"
},
"node_modules/fs-extra": {
"version": "11.3.5",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz",
"integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/jsonfile": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/log4js": {
"version": "6.9.1",
"resolved": "https://registry.npmjs.org/log4js/-/log4js-6.9.1.tgz",
"integrity": "sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==",
"license": "Apache-2.0",
"dependencies": {
"date-format": "^4.0.14",
"debug": "^4.3.4",
"flatted": "^3.2.7",
"rfdc": "^1.3.0",
"streamroller": "^3.1.5"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/streamroller": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz",
"integrity": "sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==",
"license": "MIT",
"dependencies": {
"date-format": "^4.0.14",
"debug": "^4.3.4",
"fs-extra": "^8.1.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/streamroller/node_modules/fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
},
"engines": {
"node": ">=6 <7 || >=8"
}
},
"node_modules/streamroller/node_modules/jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
"license": "MIT",
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/streamroller/node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
@@ -0,0 +1,16 @@
{
"name": "codex-monitor",
"version": "1.0.0",
"author": "StreamDock",
"main": "index.js",
"description": "Codex Desktop 工作流状态监控插件",
"scripts": {
"build": "ncc build index.js -m -o ./build && node autofile.js"
},
"license": "MIT",
"dependencies": {
"fs-extra": "^11.3.0",
"log4js": "^6.9.1",
"ws": "^8.14.2"
}
}
@@ -0,0 +1,204 @@
// 配置日志文件
const now = new Date();
const log = require('log4js').configure({
appenders: {
file: { type: 'file', filename: `./log/${now.getFullYear()}.${now.getMonth() + 1}.${now.getDate()}.log` }
},
categories: {
default: { appenders: ['file'], level: 'info' }
}
}).getLogger();
//##################################################
//##################全局异常捕获#####################
process.on('uncaughtException', (error) => {
log.error('Uncaught Exception:', error);
});
process.on('unhandledRejection', (reason) => {
log.error('Unhandled Rejection:', reason);
});
//##################################################
//##################################################
// 插件类
const ws = require('ws');
class Plugins {
static language = JSON.parse(process.argv[9]).application.language;
static globalSettings = {};
getGlobalSettingsFlag = true;
constructor() {
if (Plugins.instance) {
return Plugins.instance;
}
this.ws = new ws("ws://127.0.0.1:" + process.argv[3]);
this.ws.on('open', () => this.ws.send(JSON.stringify({ uuid: process.argv[5], event: process.argv[7] })));
this.ws.on('close', process.exit);
this.ws.on('message', e => {
if (this.getGlobalSettingsFlag) {
this.getGlobalSettingsFlag = false;
this.getGlobalSettings();
}
const data = JSON.parse(e.toString());
const action = data.action?.split('.').pop();
this[action]?.[data.event]?.(data);
if (data.event === 'didReceiveGlobalSettings') {
Plugins.globalSettings = data.payload.settings;
}
this[data.event]?.(data);
});
Plugins.instance = this;
}
setGlobalSettings(payload) {
Plugins.globalSettings = payload;
this.ws.send(JSON.stringify({
event: "setGlobalSettings",
context: process.argv[5], payload
}));
}
getGlobalSettings() {
this.ws.send(JSON.stringify({
event: "getGlobalSettings",
context: process.argv[5],
}));
}
// 设置标题
setTitle(context, str, row = 0, num = 6) {
let newStr = null;
if (row && str) {
let nowRow = 1, strArr = str.split('');
strArr.forEach((item, index) => {
if (nowRow < row && index >= nowRow * num) { nowRow++; newStr += '\n'; }
if (nowRow <= row && index < nowRow * num) { newStr += item; }
});
if (strArr.length > row * num) { newStr = newStr.substring(0, newStr.length - 1); newStr += '..'; }
}
this.ws.send(JSON.stringify({
event: "setTitle",
context, payload: {
target: 0,
title: newStr || str + ''
}
}));
}
// 设置背景
setImage(context, url) {
this.ws.send(JSON.stringify({
event: "setImage",
context, payload: {
target: 0,
image: url
}
}));
}
// 设置状态
setState(context, state) {
this.ws.send(JSON.stringify({
event: "setState",
context, payload: { state }
}));
}
// 保存持久化数据
setSettings(context, payload) {
this.ws.send(JSON.stringify({
event: "setSettings",
context, payload
}));
}
// 在按键上展示警告
showAlert(context) {
this.ws.send(JSON.stringify({
event: "showAlert",
context
}));
}
// 在按键上展示成功
showOk(context) {
this.ws.send(JSON.stringify({
event: "showOk",
context
}));
}
// 发送给属性检测器
sendToPropertyInspector(payload) {
this.ws.send(JSON.stringify({
action: Actions.currentAction,
context: Actions.currentContext,
payload, event: "sendToPropertyInspector"
}));
}
// 用默认浏览器打开网页
openUrl(url) {
this.ws.send(JSON.stringify({
event: "openUrl",
payload: { url }
}));
}
};
// 操作类
class Actions {
constructor(data) {
this.data = {};
this.default = {};
Object.assign(this, data);
}
static currentAction = null;
static currentContext = null;
static actions = {};
propertyInspectorDidAppear(data) {
Actions.currentAction = data.action;
Actions.currentContext = data.context;
this._propertyInspectorDidAppear?.(data);
}
willAppear(data) {
Plugins.globalContext = data.context;
Actions.actions[data.context] = data.action;
const { context, payload: { settings } } = data;
this.data[context] = Object.assign({ ...this.default }, settings);
this._willAppear?.(data);
}
didReceiveSettings(data) {
this.data[data.context] = data.payload.settings;
this._didReceiveSettings?.(data);
}
willDisappear(data) {
this._willDisappear?.(data);
delete this.data[data.context];
}
}
class EventEmitter {
constructor() {
this.events = {};
}
subscribe(event, listener) {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(listener);
}
unsubscribe(event, listenerToRemove) {
if (!this.events[event]) return;
this.events[event] = this.events[event].filter(listener => listener !== listenerToRemove);
}
emit(event, data) {
if (!this.events[event]) return;
this.events[event].forEach(listener => listener(data));
}
}
module.exports = {
log,
Plugins,
Actions,
EventEmitter
};
@@ -0,0 +1,133 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Codex Monitor</title>
<link rel="stylesheet" href="../utils/css/sdpi.css">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #e0e0e0;
padding: 16px;
margin: 0;
}
.section {
margin-bottom: 16px;
}
.section label {
display: block;
font-size: 12px;
color: #888;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.section input[type="range"] {
width: 100%;
accent-color: #f59e0b;
}
.section input[type="number"] {
width: 100%;
padding: 6px 8px;
background: #16213e;
border: 1px solid #333;
border-radius: 6px;
color: #e0e0e0;
font-size: 13px;
}
.section .range-row {
display: flex;
align-items: center;
gap: 8px;
}
.section .range-row span {
font-size: 12px;
color: #f59e0b;
min-width: 50px;
text-align: right;
}
.toggle {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.toggle input { display: none; }
.toggle .switch {
width: 40px; height: 22px;
background: #444;
border-radius: 11px;
position: relative;
transition: background 0.2s;
}
.toggle .switch::after {
content: '';
width: 18px; height: 18px;
background: #fff;
border-radius: 50%;
position: absolute;
top: 2px; left: 2px;
transition: transform 0.2s;
}
.toggle input:checked + .switch {
background: #f59e0b;
}
.toggle input:checked + .switch::after {
transform: translateX(18px);
}
.status-preview {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
background: #16213e;
border-radius: 8px;
margin-top: 8px;
}
.status-dot {
width: 12px; height: 12px;
border-radius: 50%;
background: #22c55e;
}
.status-dot.thinking { background: #f59e0b; }
.status-dot.working { background: #f59e0b; animation: pulse 0.8s infinite; }
.status-dot.error { background: #ef4444; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.status-text {
font-size: 14px;
font-weight: 500;
}
</style>
</head>
<body>
<div class="section">
<label data-localize="PollInterval">轮询间隔 (ms)</label>
<div class="range-row">
<input type="range" id="pollInterval" min="500" max="5000" step="100" value="1000">
<span id="pollVal">1000ms</span>
</div>
</div>
<div class="section">
<label class="toggle">
<input type="checkbox" id="showHealth" checked>
<div class="switch"></div>
<span data-localize="ShowHealth">显示连接健康状态</span>
</label>
</div>
<div class="section">
<label>状态预览</label>
<div class="status-preview">
<div class="status-dot" id="statusDot"></div>
<div class="status-text" id="statusText">就绪</div>
</div>
</div>
<script src="../utils/action.js"></script>
<script src="index.js"></script>
</body>
</html>
@@ -0,0 +1,59 @@
(function () {
'use strict';
const pollSlider = document.getElementById('pollInterval');
const pollVal = document.getElementById('pollVal');
const showHealthToggle = document.getElementById('showHealth');
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
// $settings is the SDK-provided settings proxy (auto-persists)
$settings = $settings || {};
function applySettings(settings) {
if (settings.pollInterval !== undefined) {
pollSlider.value = settings.pollInterval;
pollVal.textContent = settings.pollInterval + 'ms';
}
if (settings.showHealth !== undefined) {
showHealthToggle.checked = settings.showHealth;
}
}
pollSlider.addEventListener('input', function () {
const val = parseInt(this.value, 10);
pollVal.textContent = val + 'ms';
$settings.pollInterval = val;
});
showHealthToggle.addEventListener('change', function () {
$settings.showHealth = this.checked;
});
// 监听插件传来的状态更新
$websocket.on('sendToPropertyInspector', function (data) {
if (data.payload && data.payload.status) {
const st = data.payload.status;
statusText.textContent = st;
statusDot.className = 'status-dot';
if (st.indexOf('Think') >= 0 || st.indexOf('思考') >= 0) statusDot.classList.add('thinking');
else if (st.indexOf('Work') >= 0 || st.indexOf('执行') >= 0) statusDot.classList.add('working');
else if (st.indexOf('Error') >= 0 || st.indexOf('错误') >= 0) statusDot.classList.add('error');
else if (st.indexOf('Recon') >= 0 || st.indexOf('重连') >= 0) statusDot.classList.add('error');
}
});
// 初始化时加载已有设置
if ($settings.pollInterval) applySettings($settings);
else {
$settings.pollInterval = 1000;
$settings.showHealth = true;
}
$websocket.on('didReceiveSettings', function (data) {
applySettings(data.payload.settings);
});
// 启用自动翻译
$local = true;
})();
@@ -0,0 +1,157 @@
let $websocket, $uuid, $action, $context, $settings, $lang, $FileID = '';
WebSocket.prototype.setGlobalSettings = function(payload) {
this.send(JSON.stringify({
event: "setGlobalSettings",
context: $uuid, payload
}));
}
WebSocket.prototype.getGlobalSettings = function() {
this.send(JSON.stringify({
event: "getGlobalSettings",
context: $uuid,
}));
}
// 与插件通信
WebSocket.prototype.sendToPlugin = function (payload) {
this.send(JSON.stringify({
event: "sendToPlugin",
action: $action,
context: $uuid,
payload
}));
};
//设置标题
WebSocket.prototype.setTitle = function (str, row = 0, num = 6) {
console.log(str);
let newStr = '';
if (row) {
let nowRow = 1, strArr = str.split('');
strArr.forEach((item, index) => {
if (nowRow < row && index >= nowRow * num) { nowRow++; newStr += '\n'; }
if (nowRow <= row && index < nowRow * num) { newStr += item; }
});
if (strArr.length > row * num) { newStr = newStr.substring(0, newStr.length - 1); newStr += '..'; }
}
this.send(JSON.stringify({
event: "setTitle",
context: $context,
payload: {
target: 0,
title: newStr || str
}
}));
}
// 设置状态
WebSocket.prototype.setState = function (state) {
this.send(JSON.stringify({
event: "setState",
context: $context,
payload: { state }
}));
};
// 设置背景
WebSocket.prototype.setImage = function (url) {
let image = new Image();
image.src = url;
image.onload = () => {
let canvas = document.createElement("canvas");
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
let ctx = canvas.getContext("2d");
ctx.drawImage(image, 0, 0);
this.send(JSON.stringify({
event: "setImage",
context: $context,
payload: {
target: 0,
image: canvas.toDataURL("image/png")
}
}));
};
};
// 打开网页
WebSocket.prototype.openUrl = function (url) {
this.send(JSON.stringify({
event: "openUrl",
payload: { url }
}));
};
// 保存持久化数据
WebSocket.prototype.saveData = $.debounce(function (payload) {
this.send(JSON.stringify({
event: "setSettings",
context: $uuid,
payload
}));
});
// StreamDock 软件入口函数
const connectSocket = connectElgatoStreamDeckSocket;
async function connectElgatoStreamDeckSocket(port, uuid, event, app, info) {
info = JSON.parse(info);
$uuid = uuid; $action = info.action;
$context = info.context;
$websocket = new WebSocket('ws://127.0.0.1:' + port);
$websocket.onopen = () => $websocket.send(JSON.stringify({ event, uuid }));
// 持久数据代理
$websocket.onmessage = e => {
let data = JSON.parse(e.data);
if (data.event === 'didReceiveSettings') {
$settings = new Proxy(data.payload.settings, {
get(target, property) {
return target[property];
},
set(target, property, value) {
target[property] = value;
$websocket.saveData(data.payload.settings);
}
});
if (!$back) $dom.main.style.display = 'block';
}
$propEvent[data.event]?.(data.payload);
};
// 自动翻译页面
if (!$local) return;
$lang = await new Promise(resolve => {
const req = new XMLHttpRequest();
req.open('GET', `../../${JSON.parse(app).application.language}.json`);
req.send();
req.onreadystatechange = () => {
if (req.readyState === 4) {
resolve(JSON.parse(req.responseText).Localization);
}
};
});
// 遍历文本节点并翻译所有文本节点
const walker = document.createTreeWalker($dom.main, NodeFilter.SHOW_TEXT, (e) => {
return e.data.trim() && NodeFilter.FILTER_ACCEPT;
});
while (walker.nextNode()) {
console.log(walker.currentNode.data);
walker.currentNode.data = $lang[walker.currentNode.data];
}
// placeholder 特殊处理
const translate = item => {
if (item.placeholder?.trim()) {
console.log(item.placeholder);
item.placeholder = $lang[item.placeholder];
}
};
$('input', true).forEach(translate);
$('textarea', true).forEach(translate);
}
// StreamDock 文件路径回调
Array.from($('input[type="file"]', true)).forEach(item => item.addEventListener('click', () => $FileID = item.id));
const onFilePickerReturn = (url) => $emit.send(`File-${$FileID}`, JSON.parse(url));
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,81 @@
// 自定义事件类
class EventPlus {
constructor() {
this.event = new EventTarget();
}
on(name, callback) {
this.event.addEventListener(name, e => callback(e.detail));
}
send(name, data) {
this.event.dispatchEvent(new CustomEvent(name, {
detail: data,
bubbles: false,
cancelable: false
}));
}
}
// 补零
String.prototype.fill = function () {
return this >= 10 ? this : '0' + this;
};
// unicode编码转换字符串
String.prototype.uTs = function () {
return eval('"' + Array.from(this).join('') + '"');
};
// 字符串转换unicode编码
String.prototype.sTu = function (str = '') {
Array.from(this).forEach(item => str += `\\u${item.charCodeAt(0).toString(16)}`);
return str;
};
// 全局变量/方法
const $emit = new EventPlus(), $ = (selector, isAll = false) => {
const element = document.querySelector(selector), methods = {
on: function (event, callback) {
this.addEventListener(event, callback);
},
attr: function (name, value = '') {
value && this.setAttribute(name, value);
return this;
}
};
if (!isAll && element) {
return Object.assign(element, methods);
} else if (!isAll && !element) {
throw `HTML没有 ${selector} 元素! 请检查是否拼写错误`;
}
return Array.from(document.querySelectorAll(selector)).map(item => Object.assign(item, methods));
};
// 节流函数
$.throttle = (fn, delay) => {
let Timer = null;
return function () {
if (Timer) return;
Timer = setTimeout(() => {
fn.apply(this, arguments);
Timer = null;
}, delay);
};
};
// 防抖函数
$.debounce = (fn, delay) => {
let Timer = null;
return function () {
clearTimeout(Timer);
Timer = setTimeout(() => fn.apply(this, arguments), delay);
};
};
// 绑定限制数字方法
Array.from($('input[type="num"]', true)).forEach(item => {
item.addEventListener('input', function limitNum() {
if (!item.value || /^\d+$/.test(item.value)) return;
item.value = item.value.slice(0, -1);
limitNum(item);
});
});
@@ -0,0 +1 @@
<svg width="144" height="144" xmlns="http://www.w3.org/2000/svg"><rect width="144" height="144" rx="20" fill="#1a1a2e"/><polygon points="72,24 112,60 112,108 72,144 32,108 32,60" fill="none" stroke="#f59e0b" stroke-width="3"/><text x="72" y="84" text-anchor="middle" fill="#f59e0b" font-family="Arial,sans-serif" font-size="28" font-weight="bold">CX</text></svg>

After

Width:  |  Height:  |  Size: 363 B

@@ -0,0 +1 @@
<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="#22c55e"/><text x="72" y="118" text-anchor="middle" fill="white" font-family="Arial,sans-serif" font-size="14" font-weight="bold">🟢 Ready</text></svg>

After

Width:  |  Height:  |  Size: 311 B

@@ -0,0 +1,16 @@
{
"Name": "Codex 监控",
"Description": "监控 Codex Desktop AI 工作流状态,在 N4 按钮上实时显示",
"PollInterval": "轮询间隔 (ms)",
"ShowHealth": "显示连接健康状态",
"StatusIdle": "就绪",
"StatusPrompt": "收到提示",
"StatusThinking": "思考中",
"StatusWorking": "执行中",
"StatusTyping": "回复中",
"StatusDone": "完成",
"StatusError": "错误",
"StatusWaiting": "等待中",
"StatusReconnecting": "重连中",
"StatusRecovered": "已恢复"
}