- Hook handler now loads .env.daemon for proper config (plugin URL/secret, bot user ID) - Hook logs to /tmp/status-watcher.log instead of /dev/null - Added .env.daemon config file (.gitignored - contains tokens) - Added start-daemon.sh convenience script - Plugin mode: mobile fallback updates post message field with formatted markdown - Fixed unbounded lines array in status-watcher (capped at 50) - Added session marker to formatter output for restart recovery - Go plugin: added updatePostMessageForMobile() for dual-render strategy (webapp gets custom React component, mobile gets markdown in message field) Fixes: daemon silently dying, no plugin connection, mobile showing blank posts
174 lines
5.2 KiB
JavaScript
174 lines
5.2 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* status-formatter.js — Pure function: SessionState -> formatted Mattermost text.
|
|
*
|
|
* Output format:
|
|
* [ACTIVE] main | 38s
|
|
* Reading live-status source code...
|
|
* exec: ls /agents/sessions [OK]
|
|
* Analyzing agent configurations...
|
|
* Sub-agent: proj035-planner
|
|
* Reading protocol...
|
|
* [DONE] 28s
|
|
* [DONE] 53s | 12.4k tokens
|
|
*/
|
|
|
|
const MAX_STATUS_LINES = parseInt(process.env.MAX_STATUS_LINES, 10) || 20;
|
|
const MAX_LINE_CHARS = 120;
|
|
|
|
/**
|
|
* Format a SessionState into a Mattermost text string.
|
|
*
|
|
* @param {object} sessionState
|
|
* @param {string} sessionState.sessionKey
|
|
* @param {string} sessionState.status - 'active' | 'done' | 'error' | 'interrupted'
|
|
* @param {number} sessionState.startTime - ms since epoch
|
|
* @param {Array<string>} sessionState.lines - Status lines (most recent activity)
|
|
* @param {Array<object>} [sessionState.children] - Child session states
|
|
* @param {number} [sessionState.tokenCount] - Token count if available
|
|
* @param {string} [sessionState.agentId] - Agent ID (e.g. "main")
|
|
* @param {number} [sessionState.depth] - Nesting depth (0 = top-level)
|
|
* @returns {string}
|
|
*/
|
|
function format(sessionState, opts = {}) {
|
|
const maxLines = opts.maxLines || MAX_STATUS_LINES;
|
|
const depth = sessionState.depth || 0;
|
|
const indent = ' '.repeat(depth);
|
|
|
|
const lines = [];
|
|
|
|
// Header line
|
|
const elapsed = formatElapsed(Date.now() - sessionState.startTime);
|
|
const agentId = sessionState.agentId || extractAgentId(sessionState.sessionKey);
|
|
const statusPrefix = statusIcon(sessionState.status);
|
|
lines.push(`${indent}**${statusPrefix}** \`${agentId}\` | ${elapsed}`);
|
|
|
|
// Status lines (trimmed to maxLines, most recent)
|
|
const statusLines = (sessionState.lines || []).slice(-maxLines);
|
|
for (const line of statusLines) {
|
|
lines.push(`${indent}${formatStatusLine(truncateLine(line))}`);
|
|
}
|
|
|
|
// Child sessions (sub-agents)
|
|
if (sessionState.children && sessionState.children.length > 0) {
|
|
for (const child of sessionState.children) {
|
|
const childLines = format(child, { maxLines: Math.floor(maxLines / 2), ...opts }).split('\n');
|
|
lines.push(...childLines);
|
|
}
|
|
}
|
|
|
|
// Footer line (only for done/error/interrupted)
|
|
if (sessionState.status !== 'active') {
|
|
const tokenStr = sessionState.tokenCount
|
|
? ` | ${formatTokens(sessionState.tokenCount)} tokens`
|
|
: '';
|
|
lines.push(`${indent}**[${sessionState.status.toUpperCase()}]** ${elapsed}${tokenStr}`);
|
|
}
|
|
|
|
// Wrap in blockquote at top level — visually distinct (left border),
|
|
// never collapses like code blocks do, supports inline markdown
|
|
var body = lines.join('\n');
|
|
if (depth === 0) {
|
|
body = body
|
|
.split('\n')
|
|
.map(function (l) {
|
|
return '> ' + l;
|
|
})
|
|
.join('\n');
|
|
// Append invisible session marker for restart recovery (search by marker)
|
|
body += '\n<!-- sw:' + sessionState.sessionKey + ' -->';
|
|
}
|
|
return body;
|
|
}
|
|
|
|
/**
|
|
* Format elapsed milliseconds as human-readable string.
|
|
* @param {number} ms
|
|
* @returns {string}
|
|
*/
|
|
function formatElapsed(ms) {
|
|
if (ms < 0) ms = 0;
|
|
const s = Math.floor(ms / 1000);
|
|
const m = Math.floor(s / 60);
|
|
const h = Math.floor(m / 60);
|
|
if (h > 0) return `${h}h${m % 60}m`;
|
|
if (m > 0) return `${m}m${s % 60}s`;
|
|
return `${s}s`;
|
|
}
|
|
|
|
/**
|
|
* Format token count as compact string (e.g. 12400 -> "12.4k").
|
|
* @param {number} count
|
|
* @returns {string}
|
|
*/
|
|
function formatTokens(count) {
|
|
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
if (count >= 1000) return `${(count / 1000).toFixed(1)}k`;
|
|
return String(count);
|
|
}
|
|
|
|
/**
|
|
* Get status icon prefix.
|
|
* @param {string} status
|
|
* @returns {string}
|
|
*/
|
|
function statusIcon(status) {
|
|
switch (status) {
|
|
case 'active':
|
|
return '[ACTIVE]';
|
|
case 'done':
|
|
return '[DONE]';
|
|
case 'error':
|
|
return '[ERROR]';
|
|
case 'interrupted':
|
|
return '[INTERRUPTED]';
|
|
default:
|
|
return '[UNKNOWN]';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format a status line with inline markdown.
|
|
* Tool calls get inline code formatting; thinking text stays plain.
|
|
* @param {string} line
|
|
* @returns {string}
|
|
*/
|
|
function formatStatusLine(line) {
|
|
// Tool call lines: "toolName: arguments [OK]" or "toolName: label"
|
|
var match = line.match(/^(\S+?): (.+?)( \[OK\]| \[ERR\])?$/);
|
|
if (match) {
|
|
var marker = match[3] || '';
|
|
return '`' + match[1] + ':` ' + match[2] + marker;
|
|
}
|
|
// Thinking text — use a unicode marker to distinguish from tool calls
|
|
// Avoid markdown italic (*) since it breaks with special characters
|
|
return '\u2502 ' + line;
|
|
}
|
|
|
|
/**
|
|
* Truncate a line to MAX_LINE_CHARS.
|
|
* @param {string} line
|
|
* @returns {string}
|
|
*/
|
|
function truncateLine(line) {
|
|
if (line.length <= MAX_LINE_CHARS) return line;
|
|
return line.slice(0, MAX_LINE_CHARS - 3) + '...';
|
|
}
|
|
|
|
/**
|
|
* Extract agent ID from session key.
|
|
* Session key format: "agent:main:mattermost:channel:abc123:thread:xyz"
|
|
* @param {string} sessionKey
|
|
* @returns {string}
|
|
*/
|
|
function extractAgentId(sessionKey) {
|
|
if (!sessionKey) return 'unknown';
|
|
const parts = sessionKey.split(':');
|
|
// "agent:main:..." -> "main"
|
|
if (parts[0] === 'agent' && parts[1]) return parts[1];
|
|
return sessionKey.split(':')[0] || 'unknown';
|
|
}
|
|
|
|
module.exports = { format, formatElapsed, formatTokens, statusIcon, truncateLine, extractAgentId };
|