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:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user