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
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ coverage/
|
|||||||
*.log
|
*.log
|
||||||
plugin/server/dist/
|
plugin/server/dist/
|
||||||
plugin/webapp/node_modules/
|
plugin/webapp/node_modules/
|
||||||
|
.env.daemon
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ const { spawn } = require('child_process');
|
|||||||
// PID file location (must match watcher-manager.js default)
|
// PID file location (must match watcher-manager.js default)
|
||||||
const PID_FILE = process.env.PID_FILE || '/tmp/status-watcher.pid';
|
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)
|
// Path to watcher-manager.js (relative to this hook file's location)
|
||||||
// Hook is in: workspace/hooks/status-watcher-hook/handler.js
|
// Hook is in: workspace/hooks/status-watcher-hook/handler.js
|
||||||
// Watcher is in: workspace/projects/openclaw-live-status/src/watcher-manager.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',
|
'../../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.
|
* Check if a process is alive given its PID.
|
||||||
* Returns true if process exists and is running.
|
* 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.
|
* Spawn the watcher daemon as a detached background process.
|
||||||
* The parent (this hook handler) does not wait for it.
|
* 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...');
|
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'], {
|
const child = spawn(process.execPath, [WATCHER_PATH, 'start'], {
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: 'ignore',
|
stdio: ['ignore', logFd, logFd],
|
||||||
env: process.env,
|
env: daemonEnv,
|
||||||
});
|
});
|
||||||
|
|
||||||
child.unref();
|
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(
|
console.log(
|
||||||
'[status-watcher-hook] Watcher daemon spawned (PID will be written to',
|
'[status-watcher-hook] Watcher daemon spawned (PID will be written to',
|
||||||
PID_FILE + ')',
|
PID_FILE + ')',
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ func (p *Plugin) handleCreateSession(w http.ResponseWriter, r *http.Request) {
|
|||||||
ChannelId: req.ChannelID,
|
ChannelId: req.ChannelID,
|
||||||
RootId: req.RootID,
|
RootId: req.RootID,
|
||||||
Type: "custom_livestatus",
|
Type: "custom_livestatus",
|
||||||
Message: "", // Custom post types don't need a message
|
Message: "Agent session active",
|
||||||
}
|
}
|
||||||
post.AddProp("session_key", req.SessionKey)
|
post.AddProp("session_key", req.SessionKey)
|
||||||
post.AddProp("agent_id", req.AgentID)
|
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()})
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": appErr.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if createdPost == nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "CreatePost returned nil without error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Store session data
|
// Store session data
|
||||||
sessionData := SessionData{
|
sessionData := SessionData{
|
||||||
@@ -193,9 +197,14 @@ func (p *Plugin) handleUpdateSession(w http.ResponseWriter, r *http.Request, ses
|
|||||||
return
|
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)
|
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"})
|
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"})
|
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
|
// Helper functions
|
||||||
|
|
||||||
func readJSON(r *http.Request, v interface{}) error {
|
func readJSON(r *http.Request, v interface{}) error {
|
||||||
|
|||||||
@@ -1,21 +1,34 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
"github.com/mattermost/mattermost/server/public/model"
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// broadcastUpdate sends a WebSocket event to all clients viewing the given channel.
|
// 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".
|
// 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) {
|
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{}{
|
payload := map[string]interface{}{
|
||||||
"post_id": data.PostID,
|
"post_id": data.PostID,
|
||||||
"session_key": data.SessionKey,
|
"session_key": data.SessionKey,
|
||||||
"agent_id": data.AgentID,
|
"agent_id": data.AgentID,
|
||||||
"status": data.Status,
|
"status": data.Status,
|
||||||
"lines": data.Lines,
|
"lines": data.Lines,
|
||||||
"elapsed_ms": data.ElapsedMs,
|
"elapsed_ms": data.ElapsedMs,
|
||||||
"token_count": data.TokenCount,
|
"token_count": data.TokenCount,
|
||||||
"children": data.Children,
|
"children_json": childrenJSON,
|
||||||
"start_time_ms": data.StartTimeMs,
|
"start_time_ms": data.StartTimeMs,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ function format(sessionState, opts = {}) {
|
|||||||
return '> ' + l;
|
return '> ' + l;
|
||||||
})
|
})
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
// Append invisible session marker for restart recovery (search by marker)
|
||||||
|
body += '\n<!-- sw:' + sessionState.sessionKey + ' -->';
|
||||||
}
|
}
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ const path = require('path');
|
|||||||
const { EventEmitter } = require('events');
|
const { EventEmitter } = require('events');
|
||||||
const { resolve: resolveLabel } = require('./tool-labels');
|
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 {
|
class StatusWatcher extends EventEmitter {
|
||||||
/**
|
/**
|
||||||
* @param {object} opts
|
* @param {object} opts
|
||||||
@@ -260,6 +262,11 @@ class StatusWatcher extends EventEmitter {
|
|||||||
this._parseLine(sessionKey, state, line);
|
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
|
// Update activity timestamp
|
||||||
state.lastActivityAt = Date.now();
|
state.lastActivityAt = Date.now();
|
||||||
|
|
||||||
|
|||||||
78
start-daemon.sh
Executable file
78
start-daemon.sh
Executable file
@@ -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
|
||||||
Reference in New Issue
Block a user