From 09441b34c126368b2f188ebe1cd7045a7e26989b Mon Sep 17 00:00:00 2001 From: sol Date: Sun, 8 Mar 2026 07:42:18 +0000 Subject: [PATCH] fix: persistent daemon startup, plugin integration, mobile fallback - 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 --- .gitignore | 1 + hooks/status-watcher-hook/handler.js | 56 +++++++++++++- plugin/server/api.go | 107 ++++++++++++++++++++++++++- plugin/server/websocket.go | 29 ++++++-- src/status-formatter.js | 2 + src/status-watcher.js | 7 ++ start-daemon.sh | 78 +++++++++++++++++++ 7 files changed, 268 insertions(+), 12 deletions(-) create mode 100755 start-daemon.sh diff --git a/.gitignore b/.gitignore index 60b4b17..a18defa 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ coverage/ *.log plugin/server/dist/ plugin/webapp/node_modules/ +.env.daemon diff --git a/hooks/status-watcher-hook/handler.js b/hooks/status-watcher-hook/handler.js index f5cfc49..52f4cdb 100644 --- a/hooks/status-watcher-hook/handler.js +++ b/hooks/status-watcher-hook/handler.js @@ -22,6 +22,9 @@ const { spawn } = require('child_process'); // PID file location (must match watcher-manager.js default) const PID_FILE = process.env.PID_FILE || '/tmp/status-watcher.pid'; +// Log file location +const LOG_FILE = process.env.LIVESTATUS_LOG_FILE || '/tmp/status-watcher.log'; + // Path to watcher-manager.js (relative to this hook file's location) // Hook is in: workspace/hooks/status-watcher-hook/handler.js // Watcher is in: workspace/projects/openclaw-live-status/src/watcher-manager.js @@ -30,6 +33,12 @@ const WATCHER_PATH = path.resolve( '../../projects/openclaw-live-status/src/watcher-manager.js', ); +// Path to .env.daemon config file +const ENV_DAEMON_PATH = path.resolve( + __dirname, + '../../projects/openclaw-live-status/.env.daemon', +); + /** * Check if a process is alive given its PID. * Returns true if process exists and is running. @@ -60,6 +69,31 @@ function isWatcherRunning() { } } +/** + * Load .env.daemon file into an env object (key=value lines, ignoring comments). + * Returns merged env: process.env + .env.daemon overrides. + */ +function loadDaemonEnv() { + const env = Object.assign({}, process.env); + try { + // eslint-disable-next-line security/detect-non-literal-fs-filename + const content = fs.readFileSync(ENV_DAEMON_PATH, 'utf8'); + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx <= 0) continue; + const key = trimmed.slice(0, eqIdx).trim(); + const val = trimmed.slice(eqIdx + 1).trim(); + env[key] = val; // eslint-disable-line security/detect-object-injection + } + console.log('[status-watcher-hook] Loaded daemon config from', ENV_DAEMON_PATH); + } catch (_err) { + console.warn('[status-watcher-hook] No .env.daemon found at', ENV_DAEMON_PATH, '— using process.env only'); + } + return env; +} + /** * Spawn the watcher daemon as a detached background process. * The parent (this hook handler) does not wait for it. @@ -73,14 +107,32 @@ function spawnWatcher() { console.log('[status-watcher-hook] Starting Live Status v4 watcher daemon...'); + // Load .env.daemon for proper config (plugin URL/secret, bot user ID, etc.) + const daemonEnv = loadDaemonEnv(); + + // Open log file for append — never /dev/null + let logFd; + try { + logFd = fs.openSync(LOG_FILE, 'a'); + console.log('[status-watcher-hook] Logging to', LOG_FILE); + } catch (err) { + console.error('[status-watcher-hook] Cannot open log file', LOG_FILE, ':', err.message); + logFd = 'ignore'; + } + const child = spawn(process.execPath, [WATCHER_PATH, 'start'], { detached: true, - stdio: 'ignore', - env: process.env, + stdio: ['ignore', logFd, logFd], + env: daemonEnv, }); child.unref(); + // Close the fd in the parent process (child inherits its own copy) + if (typeof logFd === 'number') { + try { fs.closeSync(logFd); } catch (_e) { /* ignore */ } + } + console.log( '[status-watcher-hook] Watcher daemon spawned (PID will be written to', PID_FILE + ')', diff --git a/plugin/server/api.go b/plugin/server/api.go index 3b3e4e6..25b759f 100644 --- a/plugin/server/api.go +++ b/plugin/server/api.go @@ -116,7 +116,7 @@ func (p *Plugin) handleCreateSession(w http.ResponseWriter, r *http.Request) { ChannelId: req.ChannelID, RootId: req.RootID, Type: "custom_livestatus", - Message: "", // Custom post types don't need a message + Message: "Agent session active", } post.AddProp("session_key", req.SessionKey) post.AddProp("agent_id", req.AgentID) @@ -127,6 +127,10 @@ func (p *Plugin) handleCreateSession(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": appErr.Error()}) return } + if createdPost == nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "CreatePost returned nil without error"}) + return + } // Store session data sessionData := SessionData{ @@ -193,9 +197,14 @@ func (p *Plugin) handleUpdateSession(w http.ResponseWriter, r *http.Request, ses return } - // Broadcast update via WebSocket (no Mattermost post API call!) + // Broadcast update via WebSocket (for webapp — instant, no API call) p.broadcastUpdate(existing.ChannelID, *existing) + // Mobile fallback: update the post message field with formatted markdown. + // Mobile app doesn't render custom post types, so it shows the message field. + // The webapp plugin overrides rendering entirely, so "(edited)" is invisible on web. + go p.updatePostMessageForMobile(*existing) + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } @@ -229,6 +238,100 @@ func (p *Plugin) handleDeleteSession(w http.ResponseWriter, r *http.Request, ses writeJSON(w, http.StatusOK, map[string]string{"status": "done"}) } +// updatePostMessageForMobile updates the post's Message field with formatted markdown. +// This provides a mobile fallback — mobile apps don't render custom post type components +// but DO display the Message field. On webapp, the plugin's React component overrides +// rendering entirely, so the "(edited)" indicator is invisible. +func (p *Plugin) updatePostMessageForMobile(data SessionData) { + post, appErr := p.API.GetPost(data.PostID) + if appErr != nil || post == nil { + return + } + + newMessage := formatStatusMarkdown(data) + if post.Message == newMessage { + return // Skip API call if content hasn't changed + } + + post.Message = newMessage + _, updateErr := p.API.UpdatePost(post) + if updateErr != nil { + p.API.LogDebug("Failed to update post message for mobile", "postId", data.PostID, "error", updateErr.Error()) + } +} + +// formatStatusMarkdown generates a markdown blockquote status view for mobile clients. +func formatStatusMarkdown(data SessionData) string { + // Status icon + var statusIcon string + switch data.Status { + case "active": + statusIcon = "[ACTIVE]" + case "done": + statusIcon = "[DONE]" + case "error": + statusIcon = "[ERROR]" + case "interrupted": + statusIcon = "[INTERRUPTED]" + default: + statusIcon = "[UNKNOWN]" + } + + // Elapsed time + elapsed := formatElapsedMs(data.ElapsedMs) + + // Build lines + result := fmt.Sprintf("> **%s** `%s` | %s\n", statusIcon, data.AgentID, elapsed) + + // Show last N status lines (keep it compact for mobile) + maxLines := 15 + lines := data.Lines + if len(lines) > maxLines { + lines = lines[len(lines)-maxLines:] + } + for _, line := range lines { + if len(line) > 120 { + line = line[:117] + "..." + } + result += "> " + line + "\n" + } + + // Token count for completed sessions + if data.Status != "active" && data.TokenCount > 0 { + result += fmt.Sprintf("> **[%s]** %s | %s tokens\n", strings.ToUpper(data.Status), elapsed, formatTokenCount(data.TokenCount)) + } + + return result +} + +// formatElapsedMs formats milliseconds as human-readable duration. +func formatElapsedMs(ms int64) string { + if ms < 0 { + ms = 0 + } + s := ms / 1000 + m := s / 60 + h := m / 60 + if h > 0 { + return fmt.Sprintf("%dh%dm", h, m%60) + } + if m > 0 { + return fmt.Sprintf("%dm%ds", m, s%60) + } + return fmt.Sprintf("%ds", s) +} + +// formatTokenCount formats a token count compactly. +func formatTokenCount(count int) string { + if count >= 1000000 { + return fmt.Sprintf("%.1fM", float64(count)/1000000) + } + if count >= 1000 { + return fmt.Sprintf("%.1fk", float64(count)/1000) + } + return fmt.Sprintf("%d", count) +} + // Helper functions func readJSON(r *http.Request, v interface{}) error { diff --git a/plugin/server/websocket.go b/plugin/server/websocket.go index 7772a37..7dee46b 100644 --- a/plugin/server/websocket.go +++ b/plugin/server/websocket.go @@ -1,21 +1,34 @@ package main import ( + "encoding/json" + "github.com/mattermost/mattermost/server/public/model" ) // broadcastUpdate sends a WebSocket event to all clients viewing the given channel. // The event is auto-prefixed by the SDK to "custom_com.openclaw.livestatus_update". +// All values must be gob-serializable primitives (string, int64, []string, etc.). +// Complex types like []SessionData must be JSON-encoded to a string first. func (p *Plugin) broadcastUpdate(channelID string, data SessionData) { + // Serialize children to JSON string — gob cannot encode []SessionData + var childrenJSON string + if len(data.Children) > 0 { + b, err := json.Marshal(data.Children) + if err == nil { + childrenJSON = string(b) + } + } + payload := map[string]interface{}{ - "post_id": data.PostID, - "session_key": data.SessionKey, - "agent_id": data.AgentID, - "status": data.Status, - "lines": data.Lines, - "elapsed_ms": data.ElapsedMs, - "token_count": data.TokenCount, - "children": data.Children, + "post_id": data.PostID, + "session_key": data.SessionKey, + "agent_id": data.AgentID, + "status": data.Status, + "lines": data.Lines, + "elapsed_ms": data.ElapsedMs, + "token_count": data.TokenCount, + "children_json": childrenJSON, "start_time_ms": data.StartTimeMs, } diff --git a/src/status-formatter.js b/src/status-formatter.js index 374a1ad..68074fe 100644 --- a/src/status-formatter.js +++ b/src/status-formatter.js @@ -76,6 +76,8 @@ function format(sessionState, opts = {}) { return '> ' + l; }) .join('\n'); + // Append invisible session marker for restart recovery (search by marker) + body += '\n'; } return body; } diff --git a/src/status-watcher.js b/src/status-watcher.js index 7457150..058dd01 100644 --- a/src/status-watcher.js +++ b/src/status-watcher.js @@ -18,6 +18,8 @@ const path = require('path'); const { EventEmitter } = require('events'); const { resolve: resolveLabel } = require('./tool-labels'); +const MAX_LINES_BUFFER = 50; // Cap state.lines to prevent memory leaks on long sessions + class StatusWatcher extends EventEmitter { /** * @param {object} opts @@ -260,6 +262,11 @@ class StatusWatcher extends EventEmitter { this._parseLine(sessionKey, state, line); } + // Cap lines buffer to prevent unbounded growth on long sessions + if (state.lines.length > MAX_LINES_BUFFER) { + state.lines = state.lines.slice(-MAX_LINES_BUFFER); + } + // Update activity timestamp state.lastActivityAt = Date.now(); diff --git a/start-daemon.sh b/start-daemon.sh new file mode 100755 index 0000000..41eb6d0 --- /dev/null +++ b/start-daemon.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# start-daemon.sh — Start the live-status watcher daemon with proper config. +# This script is the canonical way to start the daemon. It loads env vars, +# ensures only one instance runs, and redirects logs properly. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +LOG_FILE="${LIVESTATUS_LOG_FILE:-/tmp/status-watcher.log}" +PID_FILE="${PID_FILE:-/tmp/status-watcher.pid}" +ENV_FILE="${SCRIPT_DIR}/.env.daemon" + +# Load .env.daemon if it exists +if [ -f "$ENV_FILE" ]; then + set -a + # shellcheck disable=SC1090 + source "$ENV_FILE" + set +a +fi + +# Required env vars with defaults +export MM_BOT_TOKEN="${MM_BOT_TOKEN:?MM_BOT_TOKEN is required}" +export MM_BASE_URL="${MM_BASE_URL:-https://slack.solio.tech}" +export MM_BOT_USER_ID="${MM_BOT_USER_ID:-eqtkymoej7rw7dp8xbh7hywzrr}" +export TRANSCRIPT_DIR="${TRANSCRIPT_DIR:-/home/node/.openclaw/agents}" +export LOG_LEVEL="${LOG_LEVEL:-info}" +export IDLE_TIMEOUT_S="${IDLE_TIMEOUT_S:-60}" +export SESSION_POLL_MS="${SESSION_POLL_MS:-2000}" +export MAX_ACTIVE_SESSIONS="${MAX_ACTIVE_SESSIONS:-20}" +export MAX_STATUS_LINES="${MAX_STATUS_LINES:-20}" +export HEALTH_PORT="${HEALTH_PORT:-9090}" +export PID_FILE +export OFFSET_FILE="${OFFSET_FILE:-/tmp/status-watcher-offsets.json}" + +# Plugin config (optional but recommended) +export PLUGIN_ENABLED="${PLUGIN_ENABLED:-true}" +export PLUGIN_URL="${PLUGIN_URL:-https://slack.solio.tech/plugins/com.openclaw.livestatus}" +export PLUGIN_SECRET="${PLUGIN_SECRET:-}" + +# Kill existing daemon if running +if [ -f "$PID_FILE" ]; then + OLD_PID=$(cat "$PID_FILE" 2>/dev/null || true) + if [ -n "$OLD_PID" ] && kill -0 "$OLD_PID" 2>/dev/null; then + echo "Stopping existing daemon (PID $OLD_PID)..." + kill "$OLD_PID" 2>/dev/null || true + sleep 1 + fi + rm -f "$PID_FILE" +fi + +# Start daemon with proper logging +echo "Starting status watcher daemon..." +echo " Log file: $LOG_FILE" +echo " PID file: $PID_FILE" +echo " Plugin: ${PLUGIN_ENABLED} (${PLUGIN_URL:-not configured})" + +cd "$SCRIPT_DIR" +node src/watcher-manager.js start >> "$LOG_FILE" 2>&1 & +DAEMON_PID=$! + +# Wait for PID file +for i in $(seq 1 10); do + if [ -f "$PID_FILE" ]; then + echo "Daemon started (PID $(cat "$PID_FILE"))" + exit 0 + fi + sleep 0.5 +done + +# Check if process is still running +if kill -0 "$DAEMON_PID" 2>/dev/null; then + echo "Daemon started (PID $DAEMON_PID) but PID file not created yet" + exit 0 +else + echo "ERROR: Daemon failed to start. Check $LOG_FILE" + tail -20 "$LOG_FILE" 2>/dev/null + exit 1 +fi