From 7c6c8a4432a15af7c69135a4bb993db535915411 Mon Sep 17 00:00:00 2001 From: sol Date: Sat, 7 Mar 2026 18:31:43 +0000 Subject: [PATCH] fix: production deployment issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. session-monitor: handle timestamp-prefixed transcript filenames OpenClaw uses {ISO}_{sessionId}.jsonl — glob for *_{sessionId}.jsonl when direct path doesn't exist. 2. session-monitor: skip stale sessions (>5min since last transcript write) Prevents creating status boxes for every old session in sessions.json. 3. status-watcher: parse actual OpenClaw JSONL transcript format Records are {type:'message', message:{role,content:[{type,name,...}]}} not {type:'tool_call', name}. Now shows live tool calls with arguments and assistant thinking text. 4. handler.js: fix module.exports for OpenClaw hook loader Expects default export (function), not {handle: function}. 5. HOOK.md: add YAML frontmatter metadata for hook discovery. --- hooks/status-watcher-hook/HOOK.md | 21 +++--- hooks/status-watcher-hook/handler.js | 4 +- src/session-monitor.js | 48 ++++++++++++- src/status-watcher.js | 102 +++++++++++++++++++++------ 4 files changed, 142 insertions(+), 33 deletions(-) diff --git a/hooks/status-watcher-hook/HOOK.md b/hooks/status-watcher-hook/HOOK.md index 56bf5a2..0453d03 100644 --- a/hooks/status-watcher-hook/HOOK.md +++ b/hooks/status-watcher-hook/HOOK.md @@ -1,13 +1,13 @@ +--- +name: status-watcher-hook +description: "Auto-starts the Live Status v4 watcher daemon on gateway startup" +metadata: { "openclaw": { "emoji": "📡", "events": ["gateway:startup"] } } +--- + # status-watcher-hook Auto-starts the Live Status v4 daemon when the OpenClaw gateway starts. -## Events - -```json -["gateway:startup"] -``` - ## Description On gateway startup, this hook checks whether the status-watcher daemon is already @@ -20,18 +20,17 @@ of this hook handler. The following environment variables must be set for the watcher to function: ``` -MM_TOKEN Mattermost bot token -MM_URL Mattermost base URL (e.g. https://slack.solio.tech) -TRANSCRIPT_DIR Path to agent sessions directory -SESSIONS_JSON Path to sessions.json +MM_BOT_TOKEN Mattermost bot token +MM_BASE_URL Mattermost base URL (e.g. https://slack.solio.tech) ``` Optional (defaults shown): ``` +TRANSCRIPT_DIR /home/node/.openclaw/agents THROTTLE_MS 500 IDLE_TIMEOUT_S 60 -MAX_STATUS_LINES 15 +MAX_STATUS_LINES 20 MAX_ACTIVE_SESSIONS 20 MAX_MESSAGE_CHARS 15000 HEALTH_PORT 9090 diff --git a/hooks/status-watcher-hook/handler.js b/hooks/status-watcher-hook/handler.js index e021bdc..f5cfc49 100644 --- a/hooks/status-watcher-hook/handler.js +++ b/hooks/status-watcher-hook/handler.js @@ -99,4 +99,6 @@ async function handle(_event) { spawnWatcher(); } -module.exports = { handle }; +// OpenClaw hook loader expects a default export +module.exports = handle; +module.exports.default = handle; diff --git a/src/session-monitor.js b/src/session-monitor.js index 2a59c38..a3e7819 100644 --- a/src/session-monitor.js +++ b/src/session-monitor.js @@ -113,7 +113,30 @@ class SessionMonitor extends EventEmitter { * @returns {string} */ _transcriptPath(agentId, sessionId) { - return path.join(this.transcriptDir, agentId, 'sessions', `${sessionId}.jsonl`); + const sessionsDir = path.join(this.transcriptDir, agentId, 'sessions'); + const directPath = path.join(sessionsDir, `${sessionId}.jsonl`); + + // OpenClaw may use timestamp-prefixed filenames: {ISO}_{sessionId}.jsonl + // Check direct path first, then glob for *_{sessionId}.jsonl + if (fs.existsSync(directPath)) { + return directPath; + } + + try { + const files = fs.readdirSync(sessionsDir); + const suffix = `${sessionId}.jsonl`; + const match = files.find( + (f) => f.endsWith(suffix) && f !== suffix && !f.endsWith('.deleted'), + ); + if (match) { + return path.join(sessionsDir, match); + } + } catch (_e) { + // Directory doesn't exist or unreadable + } + + // Fallback to direct path (will fail with ENOENT, which is handled upstream) + return directPath; } /** @@ -226,6 +249,29 @@ class SessionMonitor extends EventEmitter { const transcriptFile = this._transcriptPath(agentId, sessionId); + // Skip stale sessions — only track if transcript was modified in last 5 minutes + // This prevents creating status boxes for every old session in sessions.json + try { + const stat = fs.statSync(transcriptFile); + const ageMs = Date.now() - stat.mtimeMs; + const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes + if (ageMs > STALE_THRESHOLD_MS) { + if (this.logger) { + this.logger.debug( + { sessionKey, ageS: Math.floor(ageMs / 1000) }, + 'Skipping stale session (transcript not recently modified)', + ); + } + return; + } + } catch (_e) { + // File doesn't exist — skip silently + if (this.logger) { + this.logger.debug({ sessionKey, transcriptFile }, 'Skipping session (transcript not found)'); + } + return; + } + // Sub-agents always pass through — they inherit parent channel via watcher-manager const isSubAgent = !!spawnedBy; diff --git a/src/status-watcher.js b/src/status-watcher.js index bfa64c4..d7ffdbc 100644 --- a/src/status-watcher.js +++ b/src/status-watcher.js @@ -251,43 +251,106 @@ class StatusWatcher extends EventEmitter { return; } - const { type } = record; + // OpenClaw transcript format: all records have type="message" + // with record.message.role = "assistant" | "toolResult" | "user" + // and record.message.content = array of {type: "text"|"toolCall", ...} + if (record.type === 'message' && record.message) { + const msg = record.message; + const role = msg.role; - switch (type) { + if (role === 'assistant') { + // Extract usage if present + if (msg.usage && msg.usage.totalTokens) { + state.tokenCount = msg.usage.totalTokens; + } + + // Parse content items + const contentItems = Array.isArray(msg.content) ? msg.content : []; + for (var i = 0; i < contentItems.length; i++) { + var item = contentItems[i]; + + if (item.type === 'toolCall') { + state.pendingToolCalls++; + var toolName = item.name || 'unknown'; + var label = resolveLabel(toolName); + // Show tool name with key arguments + var argStr = ''; + if (item.arguments) { + if (item.arguments.command) { + argStr = item.arguments.command.slice(0, 60); + } else if (item.arguments.file_path || item.arguments.path) { + argStr = item.arguments.file_path || item.arguments.path; + } else if (item.arguments.query) { + argStr = item.arguments.query.slice(0, 60); + } else if (item.arguments.url) { + argStr = item.arguments.url.slice(0, 60); + } + } + var statusLine = argStr + ? toolName + ': ' + argStr + : toolName + ': ' + label; + state.lines.push(statusLine); + } else if (item.type === 'text' && item.text) { + var text = item.text.trim(); + if (text) { + var truncated = text.length > 100 ? text.slice(0, 97) + '...' : text; + state.lines.push(truncated); + } + } + } + } else if (role === 'toolResult') { + if (state.pendingToolCalls > 0) state.pendingToolCalls--; + var resultToolName = msg.toolName || 'unknown'; + var isError = msg.isError === true; + var marker = isError ? '[ERR]' : '[OK]'; + var idx = findLastIndex(state.lines, function (l) { + return l.indexOf(resultToolName + ':') === 0; + }); + if (idx >= 0) { + state.lines[idx] = state.lines[idx].replace(/( \[OK\]| \[ERR\])?$/, ' ' + marker); + } + } else if (role === 'user') { + // User messages — could show "User: ..." but skip for now to reduce noise + } + + return; + } + + // Legacy format fallback (for compatibility) + var legacyType = record.type; + + switch (legacyType) { case 'tool_call': { state.pendingToolCalls++; - const toolName = record.name || record.tool || 'unknown'; - const label = resolveLabel(toolName); - const statusLine = ` ${toolName}: ${label}`; - state.lines.push(statusLine); + var tn = record.name || record.tool || 'unknown'; + var lb = resolveLabel(tn); + state.lines.push(tn + ': ' + lb); break; } case 'tool_result': { if (state.pendingToolCalls > 0) state.pendingToolCalls--; - const toolName = record.name || record.tool || 'unknown'; - // Update the last tool_call line for this tool to show [OK] or [ERR] - const marker = record.error ? '[ERR]' : '[OK]'; - const idx = findLastIndex(state.lines, (l) => l.includes(` ${toolName}:`)); - if (idx >= 0) { - // Replace placeholder with result - state.lines[idx] = state.lines[idx].replace(/( \[OK\]| \[ERR\])?$/, ` ${marker}`); + var rtn = record.name || record.tool || 'unknown'; + var mk = record.error ? '[ERR]' : '[OK]'; + var ri = findLastIndex(state.lines, function (l) { + return l.indexOf(rtn + ':') === 0; + }); + if (ri >= 0) { + state.lines[ri] = state.lines[ri].replace(/( \[OK\]| \[ERR\])?$/, ' ' + mk); } break; } case 'assistant': { - // Assistant text chunk - const text = (record.text || record.content || '').trim(); - if (text) { - const truncated = text.length > 80 ? text.slice(0, 77) + '...' : text; - state.lines.push(truncated); + var at = (record.text || record.content || '').trim(); + if (at) { + var tr = at.length > 100 ? at.slice(0, 97) + '...' : at; + state.lines.push(tr); } break; } case 'usage': { - // Token usage update if (record.total_tokens) state.tokenCount = record.total_tokens; else if (record.input_tokens || record.output_tokens) { state.tokenCount = (record.input_tokens || 0) + (record.output_tokens || 0); @@ -301,7 +364,6 @@ class StatusWatcher extends EventEmitter { } default: - // Ignore unknown record types break; } }