feat: Mattermost plugin + daemon integration (Phases 2-5)

Plugin (Go server + React webapp):
- Custom post type 'custom_livestatus' with terminal-style rendering
- WebSocket broadcasts for real-time updates (no PUT, no '(edited)')
- KV store for session persistence across reconnects
- Shared secret auth for daemon-to-plugin communication
- Auto-scroll terminal with user scroll override
- Collapsible sub-agent sections
- Theme-compatible CSS (light/dark)

Daemon integration:
- PluginClient for structured data push to plugin
- Auto-detection: GET /health on startup + periodic re-check
- Graceful fallback: if plugin unavailable, uses REST API (PUT)
- Per-session mode tracking: sessions created via plugin stay on plugin
- Mid-session fallback: if plugin update fails, auto-switch to REST

Plugin deployed and active on Mattermost v11.4.0.
This commit is contained in:
sol
2026-03-07 22:11:06 +00:00
parent 868574d939
commit c724e57276
9 changed files with 282 additions and 1958 deletions

View File

@@ -24,6 +24,7 @@ const { getLogger } = require('./logger');
const { SessionMonitor } = require('./session-monitor');
const { StatusWatcher } = require('./status-watcher');
const { StatusBox } = require('./status-box');
const { PluginClient } = require('./plugin-client');
// status-formatter is used inline via require() in helpers
const { HealthServer } = require('./health');
const { loadLabels } = require('./tool-labels');
@@ -159,6 +160,36 @@ async function startDaemon() {
maxSockets: config.mm.maxSockets,
});
// Plugin client (optional — enhances rendering via WebSocket instead of PUT)
var pluginClient = null;
var usePlugin = false;
if (config.plugin && config.plugin.enabled && config.plugin.url && config.plugin.secret) {
pluginClient = new PluginClient({
pluginUrl: config.plugin.url,
secret: config.plugin.secret,
logger: logger.child({ module: 'plugin-client' }),
});
// Initial plugin detection
pluginClient.isHealthy().then(function (healthy) {
usePlugin = healthy;
logger.info({ usePlugin, url: config.plugin.url }, healthy
? 'Plugin detected — using WebSocket rendering mode'
: 'Plugin not available — using REST API fallback');
});
// Periodic re-detection
setInterval(function () {
pluginClient.isHealthy().then(function (healthy) {
if (healthy !== usePlugin) {
usePlugin = healthy;
logger.info({ usePlugin }, healthy ? 'Plugin came online' : 'Plugin went offline — fallback to REST API');
}
});
}, config.plugin.detectIntervalMs || 60000);
}
// StatusWatcher
const watcher = new StatusWatcher({
transcriptDir: config.transcriptDir,
@@ -182,6 +213,7 @@ async function startDaemon() {
...globalMetrics,
...sharedStatusBox.getMetrics(),
activeSessions: activeBoxes.size,
pluginEnabled: usePlugin,
}),
});
@@ -237,9 +269,16 @@ async function startDaemon() {
// Create new post if none found
if (!postId) {
try {
const initialText = buildInitialText(agentId, sessionKey);
postId = await sharedStatusBox.createPost(channelId, initialText, rootPostId);
logger.info({ sessionKey, postId, channelId }, 'Created status box');
if (usePlugin && pluginClient) {
// Plugin mode: create custom_livestatus post via plugin
postId = await pluginClient.createSession(sessionKey, channelId, rootPostId, agentId);
logger.info({ sessionKey, postId, channelId, mode: 'plugin' }, 'Created status box via plugin');
} else {
// REST API fallback: create regular post with formatted text
var initialText = buildInitialText(agentId, sessionKey);
postId = await sharedStatusBox.createPost(channelId, initialText, rootPostId);
logger.info({ sessionKey, postId, channelId, mode: 'rest' }, 'Created status box via REST API');
}
} catch (err) {
logger.error({ sessionKey, err }, 'Failed to create status post');
globalMetrics.lastError = err.message;
@@ -252,6 +291,7 @@ async function startDaemon() {
channelId,
agentId,
rootPostId,
usePlugin: usePlugin && !!pluginClient, // track which mode this session uses
children: new Map(),
});
globalMetrics.activeSessions = activeBoxes.size;
@@ -287,17 +327,37 @@ async function startDaemon() {
const box = activeBoxes.get(sessionKey);
if (!box) {
// Sub-agent: update parent
updateParentWithChild(activeBoxes, watcher, sharedStatusBox, sessionKey, state, logger);
updateParentWithChild(activeBoxes, watcher, sharedStatusBox, pluginClient, sessionKey, state, logger);
return;
}
// Build status text
const text = buildStatusText(box, state, activeBoxes, watcher, sessionKey);
sharedStatusBox.updatePost(box.postId, text).catch((err) => {
logger.error({ sessionKey, err }, 'Failed to update status post');
globalMetrics.lastError = err.message;
globalMetrics.updatesFailed++;
});
if (box.usePlugin && pluginClient) {
// Plugin mode: send structured data (WebSocket broadcast, no post edit)
pluginClient.updateSession(sessionKey, {
status: state.status,
lines: state.lines,
elapsed_ms: Date.now() - state.startTime,
token_count: state.tokenCount || 0,
children: (state.children || []).map(function (c) {
return { session_key: c.sessionKey, agent_id: c.agentId, status: c.status, lines: c.lines || [], elapsed_ms: Date.now() - (c.startTime || Date.now()), token_count: c.tokenCount || 0 };
}),
start_time_ms: state.startTime,
}).catch(function (err) {
// If plugin rejects (e.g. session not found), fall back to REST for this session
logger.warn({ sessionKey, err: err.message }, 'Plugin update failed — falling back to REST');
box.usePlugin = false;
var fallbackText = buildStatusText(box, state, activeBoxes, watcher, sessionKey);
sharedStatusBox.updatePost(box.postId, fallbackText).catch(function () {});
});
} else {
// REST API fallback: format text and PUT update post
var text = buildStatusText(box, state, activeBoxes, watcher, sessionKey);
sharedStatusBox.updatePost(box.postId, text).catch(function (err) {
logger.error({ sessionKey, err }, 'Failed to update status post');
globalMetrics.lastError = err.message;
globalMetrics.updatesFailed++;
});
}
// Persist offsets periodically
saveOffsets(config.offsetFile, watcher.sessions);
@@ -308,7 +368,7 @@ async function startDaemon() {
const box = activeBoxes.get(sessionKey);
if (!box) {
// Sub-agent completed
updateParentWithChild(activeBoxes, watcher, sharedStatusBox, sessionKey, state, logger);
updateParentWithChild(activeBoxes, watcher, sharedStatusBox, pluginClient, sessionKey, state, logger);
return;
}
@@ -321,12 +381,19 @@ async function startDaemon() {
// Final update with done status
const doneState = { ...state, status: 'done' };
const text = buildStatusText(box, doneState, activeBoxes, watcher, sessionKey);
try {
await sharedStatusBox.forceFlush(box.postId);
await sharedStatusBox.updatePost(box.postId, text);
logger.info({ sessionKey, postId: box.postId }, 'Session complete — status box updated');
if (box.usePlugin && pluginClient) {
// Plugin mode: mark session complete
await pluginClient.deleteSession(sessionKey);
logger.info({ sessionKey, postId: box.postId, mode: 'plugin' }, 'Session complete via plugin');
} else {
// REST API fallback
var text = buildStatusText(box, doneState, activeBoxes, watcher, sessionKey);
await sharedStatusBox.forceFlush(box.postId);
await sharedStatusBox.updatePost(box.postId, text);
logger.info({ sessionKey, postId: box.postId, mode: 'rest' }, 'Session complete — status box updated');
}
} catch (err) {
logger.error({ sessionKey, err }, 'Failed to update final status');
}
@@ -396,6 +463,7 @@ async function startDaemon() {
// Cleanup
await healthServer.stop();
sharedStatusBox.destroy();
if (pluginClient) pluginClient.destroy();
removePidFile(config.pidFile);
logger.info('Shutdown complete');
@@ -461,17 +529,31 @@ function buildStatusText(box, state, activeBoxes, watcher, _sessionKey) {
return format({ ...state, children: childStates });
}
function updateParentWithChild(activeBoxes, watcher, statusBox, childKey, childState, logger) {
function updateParentWithChild(activeBoxes, watcher, statusBox, pluginClient, childKey, childState, logger) {
// Find parent
for (const [parentKey, box] of activeBoxes) {
for (var entry of activeBoxes.entries()) {
var parentKey = entry[0];
var box = entry[1];
if (box.children && box.children.has(childKey)) {
const parentState = watcher.getSessionState(parentKey);
var parentState = watcher.getSessionState(parentKey);
if (!parentState) return;
const text = buildStatusText(box, parentState, activeBoxes, watcher, parentKey);
statusBox
.updatePost(box.postId, text)
.catch((err) => logger.error({ parentKey, childKey, err }, 'Failed to update parent'));
if (box.usePlugin && pluginClient) {
// Plugin mode: send structured update for parent
pluginClient.updateSession(parentKey, {
status: parentState.status,
lines: parentState.lines,
elapsed_ms: Date.now() - parentState.startTime,
token_count: parentState.tokenCount || 0,
children: [],
start_time_ms: parentState.startTime,
}).catch(function (err) { logger.error({ parentKey, childKey, err: err.message }, 'Failed to update parent via plugin'); });
} else {
var text = buildStatusText(box, parentState, activeBoxes, watcher, parentKey);
statusBox
.updatePost(box.postId, text)
.catch(function (err) { logger.error({ parentKey, childKey, err }, 'Failed to update parent'); });
}
return;
}
}