fix: remove dead delete+recreate and pin code, add poll fallback test

Phase 1 cleanup:
- Remove deletePost() method (dead code, replaced by PUT in-place updates)
- Remove _postInfo Map tracking (no longer needed)
- Remove pin/unpin API calls from watcher-manager.js (incompatible with PUT updates)
- Add JSDoc note on (edited) label limitation in _flushUpdate()
- Add integration test: test/integration/poll-fallback.test.js
- Fix addSession() lastOffset===0 falsy bug (0 was treated as 'no offset')
- Fix pre-existing test failures: add lastOffset:0 where tests expect backlog reads
- Fix pre-existing session-monitor test: create stub transcript files
- Fix pre-existing status-formatter test: update indent check for blockquote format
- Format plugin/ files with Prettier (pre-existing formatting drift)
This commit is contained in:
sol
2026-03-07 20:31:32 +00:00
parent cc485f0009
commit 868574d939
31 changed files with 3596 additions and 68 deletions

View File

@@ -1,7 +1,7 @@
--- ---
name: status-watcher-hook name: status-watcher-hook
description: "Auto-starts the Live Status v4 watcher daemon on gateway startup" description: 'Auto-starts the Live Status v4 watcher daemon on gateway startup'
metadata: { "openclaw": { "emoji": "📡", "events": ["gateway:startup"] } } metadata: { 'openclaw': { 'emoji': '📡', 'events': ['gateway:startup'] } }
--- ---
# status-watcher-hook # status-watcher-hook

44
plugin/plugin.json Normal file
View File

@@ -0,0 +1,44 @@
{
"id": "com.openclaw.livestatus",
"name": "OpenClaw Live Status",
"description": "Real-time agent status streaming with custom post type rendering and WebSocket updates.",
"homepage_url": "https://git.eeqj.de/ROOH/MATTERMOST_OPENCLAW_LIVESTATUS",
"support_url": "https://git.eeqj.de/ROOH/MATTERMOST_OPENCLAW_LIVESTATUS/issues",
"icon_path": "assets/icon.svg",
"min_server_version": "7.0.0",
"server": {
"executables": {
"linux-amd64": "server/dist/plugin-linux-amd64"
}
},
"webapp": {
"bundle_path": "webapp/dist/main.js"
},
"settings_schema": {
"header": "Configure the OpenClaw Live Status plugin.",
"footer": "",
"settings": [
{
"key": "SharedSecret",
"display_name": "Shared Secret",
"type": "text",
"help_text": "Shared secret for authenticating the watcher daemon. Must match the daemon's PLUGIN_SECRET env var.",
"default": ""
},
{
"key": "MaxActiveSessions",
"display_name": "Max Active Sessions",
"type": "number",
"help_text": "Maximum number of simultaneously tracked agent sessions.",
"default": 20
},
{
"key": "MaxStatusLines",
"display_name": "Max Status Lines",
"type": "number",
"help_text": "Maximum number of status lines to display per session.",
"default": 30
}
]
}
}

234
plugin/server/api.go Normal file
View File

@@ -0,0 +1,234 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
)
// ServeHTTP handles HTTP requests to the plugin.
func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) {
// Auth middleware: validate shared secret
config := p.getConfiguration()
if config.SharedSecret != "" {
auth := r.Header.Get("Authorization")
expected := "Bearer " + config.SharedSecret
if auth != expected {
http.Error(w, `{"error": "unauthorized"}`, http.StatusUnauthorized)
return
}
}
path := r.URL.Path
switch {
case path == "/api/v1/health" && r.Method == http.MethodGet:
p.handleHealth(w, r)
case path == "/api/v1/sessions" && r.Method == http.MethodGet:
p.handleListSessions(w, r)
case path == "/api/v1/sessions" && r.Method == http.MethodPost:
p.handleCreateSession(w, r)
case strings.HasPrefix(path, "/api/v1/sessions/") && r.Method == http.MethodPut:
sessionKey := strings.TrimPrefix(path, "/api/v1/sessions/")
p.handleUpdateSession(w, r, sessionKey)
case strings.HasPrefix(path, "/api/v1/sessions/") && r.Method == http.MethodDelete:
sessionKey := strings.TrimPrefix(path, "/api/v1/sessions/")
p.handleDeleteSession(w, r, sessionKey)
default:
http.NotFound(w, r)
}
}
// handleHealth returns plugin health status.
func (p *Plugin) handleHealth(w http.ResponseWriter, r *http.Request) {
sessions, err := p.store.ListActiveSessions()
count := 0
if err == nil {
count = len(sessions)
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "healthy",
"active_sessions": count,
"plugin_id": "com.openclaw.livestatus",
})
}
// handleListSessions returns all active sessions.
func (p *Plugin) handleListSessions(w http.ResponseWriter, r *http.Request) {
sessions, err := p.store.ListActiveSessions()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, sessions)
}
// CreateSessionRequest is the request body for creating a new session.
type CreateSessionRequest struct {
SessionKey string `json:"session_key"`
ChannelID string `json:"channel_id"`
RootID string `json:"root_id,omitempty"`
AgentID string `json:"agent_id"`
}
// handleCreateSession creates a new custom_livestatus post and starts tracking.
func (p *Plugin) handleCreateSession(w http.ResponseWriter, r *http.Request) {
var req CreateSessionRequest
if err := readJSON(r, &req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if req.SessionKey == "" || req.ChannelID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "session_key and channel_id required"})
return
}
// Check max active sessions
config := p.getConfiguration()
sessions, _ := p.store.ListActiveSessions()
if len(sessions) >= config.MaxActiveSessions {
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "max active sessions reached"})
return
}
// Create the custom post
post := &model.Post{
ChannelId: req.ChannelID,
RootId: req.RootID,
Type: "custom_livestatus",
Message: "", // Custom post types don't need a message
}
post.AddProp("session_key", req.SessionKey)
post.AddProp("agent_id", req.AgentID)
post.AddProp("status", "active")
createdPost, appErr := p.API.CreatePost(post)
if appErr != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": appErr.Error()})
return
}
// Store session data
sessionData := SessionData{
SessionKey: req.SessionKey,
PostID: createdPost.Id,
ChannelID: req.ChannelID,
RootID: req.RootID,
AgentID: req.AgentID,
Status: "active",
Lines: []string{},
}
if err := p.store.SaveSession(req.SessionKey, sessionData); err != nil {
p.API.LogWarn("Failed to save session", "error", err.Error())
}
// Broadcast initial state
p.broadcastUpdate(req.ChannelID, sessionData)
writeJSON(w, http.StatusCreated, map[string]string{
"post_id": createdPost.Id,
"session_key": req.SessionKey,
})
}
// UpdateSessionRequest is the request body for updating a session.
type UpdateSessionRequest struct {
Status string `json:"status"`
Lines []string `json:"lines"`
ElapsedMs int64 `json:"elapsed_ms"`
TokenCount int `json:"token_count"`
Children []SessionData `json:"children,omitempty"`
StartTimeMs int64 `json:"start_time_ms"`
}
// handleUpdateSession updates session data and broadcasts via WebSocket.
// Critically: does NOT call any Mattermost post API — no "(edited)" label.
func (p *Plugin) handleUpdateSession(w http.ResponseWriter, r *http.Request, sessionKey string) {
var req UpdateSessionRequest
if err := readJSON(r, &req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
// Get existing session
existing, err := p.store.GetSession(sessionKey)
if err != nil || existing == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "session not found"})
return
}
// Update fields
existing.Status = req.Status
existing.Lines = req.Lines
existing.ElapsedMs = req.ElapsedMs
existing.TokenCount = req.TokenCount
existing.Children = req.Children
if req.StartTimeMs > 0 {
existing.StartTimeMs = req.StartTimeMs
}
// Save to KV store
if err := p.store.SaveSession(sessionKey, *existing); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
// Broadcast update via WebSocket (no Mattermost post API call!)
p.broadcastUpdate(existing.ChannelID, *existing)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handleDeleteSession marks a session as complete.
func (p *Plugin) handleDeleteSession(w http.ResponseWriter, r *http.Request, sessionKey string) {
existing, err := p.store.GetSession(sessionKey)
if err != nil || existing == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "session not found"})
return
}
// Mark as done
existing.Status = "done"
// Update the post props to reflect completion (one final API call)
post, appErr := p.API.GetPost(existing.PostID)
if appErr == nil && post != nil {
post.AddProp("status", "done")
post.AddProp("final_lines", existing.Lines)
post.AddProp("elapsed_ms", existing.ElapsedMs)
post.AddProp("token_count", existing.TokenCount)
_, _ = p.API.UpdatePost(post)
}
// Broadcast final state
p.broadcastUpdate(existing.ChannelID, *existing)
// Clean up KV store
_ = p.store.DeleteSession(sessionKey)
writeJSON(w, http.StatusOK, map[string]string{"status": "done"})
}
// Helper functions
func readJSON(r *http.Request, v interface{}) error {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
if err != nil {
return fmt.Errorf("read body: %w", err)
}
defer r.Body.Close()
return json.Unmarshal(body, v)
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}

View File

@@ -0,0 +1,71 @@
package main
import (
"fmt"
)
// Configuration holds the plugin settings from the Admin Console.
type Configuration struct {
SharedSecret string `json:"SharedSecret"`
MaxActiveSessions int `json:"MaxActiveSessions"`
MaxStatusLines int `json:"MaxStatusLines"`
}
// Clone returns a shallow copy of the configuration.
func (c *Configuration) Clone() *Configuration {
var clone Configuration
clone = *c
return &clone
}
// IsValid checks if the configuration is valid.
func (c *Configuration) IsValid() error {
if c.SharedSecret == "" {
return fmt.Errorf("SharedSecret must not be empty")
}
if c.MaxActiveSessions <= 0 {
c.MaxActiveSessions = 20
}
if c.MaxStatusLines <= 0 {
c.MaxStatusLines = 30
}
return nil
}
// getConfiguration retrieves the active configuration under lock.
func (p *Plugin) getConfiguration() *Configuration {
p.configurationLock.RLock()
defer p.configurationLock.RUnlock()
if p.configuration == nil {
return &Configuration{
MaxActiveSessions: 20,
MaxStatusLines: 30,
}
}
return p.configuration
}
// OnConfigurationChange is invoked when configuration changes may have been made.
func (p *Plugin) OnConfigurationChange() error {
var configuration = new(Configuration)
if err := p.API.LoadPluginConfiguration(configuration); err != nil {
return fmt.Errorf("failed to load plugin configuration: %w", err)
}
// Apply defaults
if configuration.MaxActiveSessions <= 0 {
configuration.MaxActiveSessions = 20
}
if configuration.MaxStatusLines <= 0 {
configuration.MaxStatusLines = 30
}
p.configurationLock.Lock()
p.configuration = configuration
p.configurationLock.Unlock()
return nil
}

BIN
plugin/server/dist/plugin-linux-amd64 vendored Executable file

Binary file not shown.

46
plugin/server/go.mod Normal file
View File

@@ -0,0 +1,46 @@
module github.com/openclaw/mattermost-plugin-livestatus/server
go 1.22.12
require github.com/mattermost/mattermost/server/public v0.0.12
require (
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/francoispqt/gojay v1.2.13 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect
github.com/go-sql-driver/mysql v1.7.1 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/uuid v1.5.0 // indirect
github.com/gorilla/websocket v1.5.1 // indirect
github.com/hashicorp/go-hclog v1.6.2 // indirect
github.com/hashicorp/go-plugin v1.6.0 // indirect
github.com/hashicorp/yamux v0.1.1 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 // indirect
github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 // indirect
github.com/mattermost/logr/v2 v2.0.21 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
github.com/oklog/run v1.1.0 // indirect
github.com/pborman/uuid v1.2.1 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/philhofer/fwd v1.1.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/tinylib/msgp v1.1.9 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/wiggin77/merror v1.0.5 // indirect
github.com/wiggin77/srslog v1.0.1 // indirect
golang.org/x/crypto v0.16.0 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20231212172506-995d672761c0 // indirect
google.golang.org/grpc v1.60.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)

288
plugin/server/go.sum Normal file
View File

@@ -0,0 +1,288 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.31.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.37.0/go.mod h1:TS1dMSSfndXH133OKGwekG838Om/cQT0BUHV3HcBgoo=
dmitri.shuralyov.com/app/changes v0.0.0-20180602232624-0a106ad413e3/go.mod h1:Yl+fi1br7+Rr3LqpNJf1/uxUdtRUV+Tnj0o93V2B9MU=
dmitri.shuralyov.com/html/belt v0.0.0-20180602232347-f7d459c86be0/go.mod h1:JLBrvjyP0v+ecvNYvCpyZgu5/xkfAUhi6wJj28eUfSU=
dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1:a1inKt/atXimZ4Mv927x+r7UpyzRUf4emIoiiSC2TN4=
dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D6DFvNNtx+9ybjezNCa8XF0xaYcETyp6rHWU=
git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g=
github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA=
github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8=
github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a h1:etIrTD8BQqzColk9nKRusM9um5+1q0iOEJLqfBMIK64=
github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a/go.mod h1:emQhSYTXqB0xxjLITTw4EaWZ+8IIQYw+kx9GqNUKdLg=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk=
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA=
github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go v2.0.0+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY=
github.com/googleapis/gax-go/v2 v2.0.3/go.mod h1:LLvjysVCY1JZeum8Z6l8qUty8fiNwE08qbEPm1M08qg=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw=
github.com/hashicorp/go-hclog v1.6.2 h1:NOtoftovWkDheyUM/8JW3QMiXyxJK3uHRK7wV04nD2I=
github.com/hashicorp/go-hclog v1.6.2/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-plugin v1.6.0 h1:wgd4KxHJTVGGqWBq4QPB1i5BZNEx9BR8+OFmHDmTk8A=
github.com/hashicorp/go-plugin v1.6.0/go.mod h1:lBS5MtSSBZk0SHc66KACcjjlU6WzEVP/8pwz68aMkCI=
github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU=
github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c=
github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI=
github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 h1:Khvh6waxG1cHc4Cz5ef9n3XVCxRWpAKUtqg9PJl5+y8=
github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404/go.mod h1:RyS7FDNQlzF1PsjbJWHRI35exqaKGSO9qD4iv8QjE34=
github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 h1:Y1Tu/swM31pVwwb2BTCsOdamENjjWCI6qmfHLbk6OZI=
github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956/go.mod h1:SRl30Lb7/QoYyohYeVBuqYvvmXSZJxZgiV3Zf6VbxjI=
github.com/mattermost/logr/v2 v2.0.21 h1:CMHsP+nrbRlEC4g7BwOk1GAnMtHkniFhlSQPXy52be4=
github.com/mattermost/logr/v2 v2.0.21/go.mod h1:kZkB/zqKL9e+RY5gB3vGpsyenC+TpuiOenjMkvJJbzc=
github.com/mattermost/mattermost/server/public v0.0.12 h1:iunc9q4/XkArOrndEUn73uFw6v9TOEXEtp6Nm6Iv218=
github.com/mattermost/mattermost/server/public v0.0.12/go.mod h1:Bk+atJcELCIk9Yeq5FoqTr+gra9704+X4amrlwlTgSc=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4=
github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo=
github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM=
github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8=
github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw=
github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw=
github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY=
github.com/shurcooL/events v0.0.0-20181021180414-410e4ca65f48/go.mod h1:5u70Mqkb5O5cxEA8nxTsgrgLehJeAw6Oc4Ab1c/P1HM=
github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0=
github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ=
github.com/shurcooL/gofontwoff v0.0.0-20180329035133-29b52fc0a18d/go.mod h1:05UtEgK5zq39gLST6uB0cf3NEHjETfB4Fgr3Gx5R9Vw=
github.com/shurcooL/gopherjslib v0.0.0-20160914041154-feb6d3990c2c/go.mod h1:8d3azKNyqcHP1GaQE/c6dDgjkgSx2BZ4IoEi4F1reUI=
github.com/shurcooL/highlight_diff v0.0.0-20170515013008-09bb4053de1b/go.mod h1:ZpfEhSmds4ytuByIcDnOLkTHGUI6KNqRNPDLHDk+mUU=
github.com/shurcooL/highlight_go v0.0.0-20181028180052-98c3abbbae20/go.mod h1:UDKB5a1T23gOMUJrI+uSuH0VRDStOiUVSjBTRDVBVag=
github.com/shurcooL/home v0.0.0-20181020052607-80b7ffcb30f9/go.mod h1:+rgNQw2P9ARFAs37qieuu7ohDNQ3gds9msbT2yn85sg=
github.com/shurcooL/htmlg v0.0.0-20170918183704-d01228ac9e50/go.mod h1:zPn1wHpTIePGnXSHpsVPWEktKXHr6+SS6x/IKRb7cpw=
github.com/shurcooL/httperror v0.0.0-20170206035902-86b7830d14cc/go.mod h1:aYMfkZ6DWSJPJ6c4Wwz3QtW22G7mf/PEgaB9k/ik5+Y=
github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
github.com/shurcooL/httpgzip v0.0.0-20180522190206-b1c53ac65af9/go.mod h1:919LwcH0M7/W4fcZ0/jy0qGght1GIhqyS/EgWGH2j5Q=
github.com/shurcooL/issues v0.0.0-20181008053335-6292fdc1e191/go.mod h1:e2qWDig5bLteJ4fwvDAc2NHzqFEthkqn7aOZAOpj+PQ=
github.com/shurcooL/issuesapp v0.0.0-20180602232740-048589ce2241/go.mod h1:NPpHK2TI7iSaM0buivtFUc9offApnI0Alt/K8hcHy0I=
github.com/shurcooL/notifications v0.0.0-20181007000457-627ab5aea122/go.mod h1:b5uSkrEVM1jQUspwbixRBhaIjIzL2xazXp6kntxYle0=
github.com/shurcooL/octicon v0.0.0-20181028054416-fa4f57f9efb2/go.mod h1:eWdoE5JD4R5UVWDucdOPg1g2fqQRq78IQa9zlOV1vpQ=
github.com/shurcooL/reactions v0.0.0-20181006231557-f2e0b4ca5b82/go.mod h1:TCR1lToEk4d2s07G3XGfz2QrgHXg4RJBvjrOozvoWfk=
github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/shurcooL/users v0.0.0-20180125191416-49c67e49c537/go.mod h1:QJTqeLYEDaXHZDBsXlPCDqdhQuJkuw4NOtaxYe3xii4=
github.com/shurcooL/webdavfs v0.0.0-20170829043945-18c3829fa133/go.mod h1:hKmq5kWdCj2z2KEozexVbfEZIWiTjhE0+UjmZgPqehw=
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE=
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
github.com/tinylib/msgp v1.1.9 h1:SHf3yoO2sGA0veCJeCBYLHuttAVFHGm2RHgNodW7wQU=
github.com/tinylib/msgp v1.1.9/go.mod h1:BCXGB54lDD8qUEPmiG0cQQUANC4IUQyB2ItS2UDlO/k=
github.com/viant/assertly v0.4.8/go.mod h1:aGifi++jvCrUaklKEKT0BU95igDNaqkvz+49uaYMPRU=
github.com/viant/toolbox v0.24.0/go.mod h1:OxMCG57V0PXuIP2HNQrtJf2CjqdmbrOx5EkMILuUhzM=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/wiggin77/merror v1.0.5 h1:P+lzicsn4vPMycAf2mFf7Zk6G9eco5N+jB1qJ2XW3ME=
github.com/wiggin77/merror v1.0.5/go.mod h1:H2ETSu7/bPE0Ymf4bEwdUoo73OOEkdClnoRisfw0Nm0=
github.com/wiggin77/srslog v1.0.1 h1:gA2XjSMy3DrRdX9UqLuDtuVAAshb8bE1NhX1YK0Qe+8=
github.com/wiggin77/srslog v1.0.1/go.mod h1:fehkyYDq1QfuYn60TDPu9YdY2bB85VUW2mvN1WynEls=
go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA=
go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE=
golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+MfF/py+xQ/tYqIWW03b70/CG9Rw=
golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181029044818-c44066c5c816/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190313220215-9f648a60d977/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/perf v0.0.0-20180704124530-6e6d33e29852/go.mod h1:JLpeXjPJfIyPr5TlbXLkXWLhP8nz10XfvxElABhCtcw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181029174526-d69651ed3497/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190316082340-a2f829d7f35f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181030000716-a0a13e073c7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=
google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=
google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20181202183823-bd91e49a0898/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg=
google.golang.org/genproto v0.0.0-20190306203927-b5d61aea6440/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20231212172506-995d672761c0 h1:/jFB8jK5R3Sq3i/lmeZO0cATSzFfZaJq1J2Euan3XKU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20231212172506-995d672761c0/go.mod h1:FUoWkonphQm3RhTS+kOEhF8h0iDpm4tdXolVCeZ9KKA=
google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio=
google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.60.0 h1:6FQAR0kM31P6MRdeluor2w2gPaS4SVNrD/DNTxrQ15k=
google.golang.org/grpc v1.60.0/go.mod h1:OlCHIeLYqSSsLi6i49B5QGdzaMZK9+M7LXN2FKz4eGM=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
sourcegraph.com/sourcegraph/go-diff v0.5.0/go.mod h1:kuch7UrkMzY0X+p9CRK03kfuPQ2zzQcaEFbx8wA8rck=
sourcegraph.com/sqs/pbtypes v0.0.0-20180604144634-d3ebe8f20ae4/go.mod h1:ketZ/q3QxT9HOBeFhu6RdvsftgpsbFHBF5Cas6cDKZ0=

9
plugin/server/main.go Normal file
View File

@@ -0,0 +1,9 @@
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
)
func main() {
plugin.ClientMain(&Plugin{})
}

47
plugin/server/plugin.go Normal file
View File

@@ -0,0 +1,47 @@
package main
import (
"sync"
"github.com/mattermost/mattermost/server/public/plugin"
)
// Plugin implements the Mattermost plugin interface.
type Plugin struct {
plugin.MattermostPlugin
// configurationLock synchronizes access to the configuration.
configurationLock sync.RWMutex
// configuration is the active plugin configuration.
configuration *Configuration
// store wraps KV store operations for session persistence.
store *Store
}
// OnActivate is called when the plugin is activated.
func (p *Plugin) OnActivate() error {
p.store = NewStore(p.API)
p.API.LogInfo("OpenClaw Live Status plugin activated")
return nil
}
// OnDeactivate is called when the plugin is deactivated.
func (p *Plugin) OnDeactivate() error {
// Mark all active sessions as interrupted
sessions, err := p.store.ListActiveSessions()
if err != nil {
p.API.LogWarn("Failed to list sessions on deactivate", "error", err.Error())
} else {
for _, s := range sessions {
s.Status = "interrupted"
_ = p.store.SaveSession(s.SessionKey, s)
p.broadcastUpdate(s.ChannelID, s)
}
}
p.API.LogInfo("OpenClaw Live Status plugin deactivated")
return nil
}

117
plugin/server/store.go Normal file
View File

@@ -0,0 +1,117 @@
package main
import (
"encoding/json"
"fmt"
"net/url"
"strings"
"github.com/mattermost/mattermost/server/public/plugin"
)
const kvPrefix = "ls_session_"
// SessionData represents a tracked agent session.
type SessionData struct {
SessionKey string `json:"session_key"`
PostID string `json:"post_id"`
ChannelID string `json:"channel_id"`
RootID string `json:"root_id,omitempty"`
AgentID string `json:"agent_id"`
Status string `json:"status"` // active, done, error, interrupted
Lines []string `json:"lines"`
ElapsedMs int64 `json:"elapsed_ms"`
TokenCount int `json:"token_count"`
Children []SessionData `json:"children,omitempty"`
StartTimeMs int64 `json:"start_time_ms"`
}
// Store wraps Mattermost KV store operations for session persistence.
type Store struct {
api plugin.API
}
// NewStore creates a new Store instance.
func NewStore(api plugin.API) *Store {
return &Store{api: api}
}
// encodeKey URL-encodes a session key for safe KV storage.
func encodeKey(sessionKey string) string {
return kvPrefix + url.PathEscape(sessionKey)
}
// SaveSession stores a session in the KV store.
func (s *Store) SaveSession(sessionKey string, data SessionData) error {
b, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("marshal session: %w", err)
}
appErr := s.api.KVSet(encodeKey(sessionKey), b)
if appErr != nil {
return fmt.Errorf("kv set: %s", appErr.Error())
}
return nil
}
// GetSession retrieves a session from the KV store.
func (s *Store) GetSession(sessionKey string) (*SessionData, error) {
b, appErr := s.api.KVGet(encodeKey(sessionKey))
if appErr != nil {
return nil, fmt.Errorf("kv get: %s", appErr.Error())
}
if b == nil {
return nil, nil
}
var data SessionData
if err := json.Unmarshal(b, &data); err != nil {
return nil, fmt.Errorf("unmarshal session: %w", err)
}
return &data, nil
}
// DeleteSession removes a session from the KV store.
func (s *Store) DeleteSession(sessionKey string) error {
appErr := s.api.KVDelete(encodeKey(sessionKey))
if appErr != nil {
return fmt.Errorf("kv delete: %s", appErr.Error())
}
return nil
}
// ListActiveSessions returns all active sessions from the KV store.
func (s *Store) ListActiveSessions() ([]SessionData, error) {
var sessions []SessionData
page := 0
perPage := 100
for {
keys, appErr := s.api.KVList(page, perPage)
if appErr != nil {
return nil, fmt.Errorf("kv list: %s", appErr.Error())
}
if len(keys) == 0 {
break
}
for _, key := range keys {
if !strings.HasPrefix(key, kvPrefix) {
continue
}
b, getErr := s.api.KVGet(key)
if getErr != nil || b == nil {
continue
}
var data SessionData
if err := json.Unmarshal(b, &data); err != nil {
continue
}
if data.Status == "active" {
sessions = append(sessions, data)
}
}
page++
}
return sessions, nil
}

View File

@@ -0,0 +1,25 @@
package main
import (
"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".
func (p *Plugin) broadcastUpdate(channelID string, data SessionData) {
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,
"start_time_ms": data.StartTimeMs,
}
p.API.PublishWebSocketEvent("update", payload, &model.WebsocketBroadcast{
ChannelId: channelID,
})
}

1934
plugin/webapp/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
{
"name": "com.openclaw.livestatus-webapp",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "webpack --mode=production",
"dev": "webpack --mode=development --watch"
},
"devDependencies": {
"css-loader": "^6.11.0",
"style-loader": "^3.3.4",
"ts-loader": "^9.5.4",
"typescript": "^5.9.3",
"webpack": "^5.105.4",
"webpack-cli": "^5.1.4"
},
"dependencies": {
"@types/react": "^18.2.0"
}
}

View File

@@ -0,0 +1,180 @@
import React, { useState, useEffect } from 'react';
import TerminalView from './terminal_view';
import { LiveStatusData } from '../types';
interface LiveStatusPostProps {
post: {
id: string;
props: Record<string, any>;
};
theme: Record<string, string>;
}
// Global store for WebSocket updates (set by the plugin index)
declare global {
interface Window {
__livestatus_updates: Record<string, LiveStatusData>;
__livestatus_listeners: Record<string, Array<(data: LiveStatusData) => void>>;
}
}
if (typeof window !== 'undefined') {
window.__livestatus_updates = window.__livestatus_updates || {};
window.__livestatus_listeners = window.__livestatus_listeners || {};
}
/**
* Subscribe to live status updates for a given post ID.
*/
function useStatusUpdates(
postId: string,
initialData: LiveStatusData | null,
): LiveStatusData | null {
const [data, setData] = useState<LiveStatusData | null>(
window.__livestatus_updates[postId] || initialData,
);
useEffect(() => {
// Register listener
if (!window.__livestatus_listeners[postId]) {
window.__livestatus_listeners[postId] = [];
}
const listener = (newData: LiveStatusData) => setData(newData);
window.__livestatus_listeners[postId].push(listener);
// Check if we already have data
if (window.__livestatus_updates[postId]) {
setData(window.__livestatus_updates[postId]);
}
return () => {
const listeners = window.__livestatus_listeners[postId];
if (listeners) {
const idx = listeners.indexOf(listener);
if (idx >= 0) listeners.splice(idx, 1);
}
};
}, [postId]);
return data;
}
/**
* Format elapsed time in human-readable form.
*/
function formatElapsed(ms: number): string {
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 compactly.
*/
function formatTokens(count: number): string {
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(1)}k`;
return String(count);
}
/**
* LiveStatusPost — custom post type component.
* Renders a terminal-style live status view with auto-updating content.
*/
const LiveStatusPost: React.FC<LiveStatusPostProps> = ({ post, theme }) => {
const [elapsed, setElapsed] = useState(0);
// Build initial data from post props
const initialData: LiveStatusData = {
session_key: post.props.session_key || '',
post_id: post.id,
agent_id: post.props.agent_id || 'unknown',
status: post.props.status || 'active',
lines: post.props.final_lines || [],
elapsed_ms: post.props.elapsed_ms || 0,
token_count: post.props.token_count || 0,
children: [],
start_time_ms: post.props.start_time_ms || 0,
};
const data = useStatusUpdates(post.id, initialData);
const isActive = data?.status === 'active';
// Client-side elapsed time counter (ticks every 1s when active)
useEffect(() => {
if (!isActive || !data?.start_time_ms) return;
const interval = setInterval(() => {
setElapsed(Date.now() - data.start_time_ms);
}, 1000);
return () => clearInterval(interval);
}, [isActive, data?.start_time_ms]);
if (!data) {
return <div className="ls-post ls-loading">Loading status...</div>;
}
const displayElapsed =
isActive && data.start_time_ms
? formatElapsed(elapsed || Date.now() - data.start_time_ms)
: formatElapsed(data.elapsed_ms);
const statusClass = `ls-status-${data.status}`;
return (
<div className={`ls-post ${statusClass}`}>
<div className="ls-header">
<span className="ls-agent-badge">{data.agent_id}</span>
{isActive && <span className="ls-live-dot" />}
<span className={`ls-status-badge ${statusClass}`}>{data.status.toUpperCase()}</span>
<span className="ls-elapsed">{displayElapsed}</span>
</div>
<TerminalView lines={data.lines} maxLines={30} />
{data.children && data.children.length > 0 && (
<div className="ls-children">
{data.children.map((child, i) => (
<ChildSession key={child.session_key || i} child={child} />
))}
</div>
)}
{!isActive && data.token_count > 0 && (
<div className="ls-footer">{formatTokens(data.token_count)} tokens</div>
)}
</div>
);
};
/**
* Renders a child/sub-agent session (collapsed by default).
*/
const ChildSession: React.FC<{ child: LiveStatusData }> = ({ child }) => {
const [expanded, setExpanded] = useState(false);
return (
<div className="ls-child">
<div
className="ls-child-header"
onClick={() => setExpanded(!expanded)}
style={{ cursor: 'pointer' }}
>
<span className="ls-expand-icon">{expanded ? '\u25BC' : '\u25B6'}</span>
<span className="ls-agent-badge ls-child-badge">{child.agent_id}</span>
<span className={`ls-status-badge ls-status-${child.status}`}>
{child.status.toUpperCase()}
</span>
<span className="ls-elapsed">{formatElapsed(child.elapsed_ms)}</span>
</div>
{expanded && <TerminalView lines={child.lines} maxLines={15} />}
</div>
);
};
export default LiveStatusPost;

View File

@@ -0,0 +1,41 @@
import React from 'react';
interface StatusLineProps {
line: string;
}
/**
* Renders a single status line with formatting:
* - Tool calls: monospace tool name with colored result marker
* - Thinking text: dimmed with box-drawing prefix
*/
const StatusLine: React.FC<StatusLineProps> = ({ line }) => {
// Match tool call pattern: "toolName: arguments [OK]" or "toolName: arguments [ERR]"
const toolMatch = line.match(/^(\S+?): (.+?)( \[OK\]| \[ERR\])?$/);
if (toolMatch) {
const toolName = toolMatch[1];
const args = toolMatch[2];
const marker = (toolMatch[3] || '').trim();
return (
<div className="ls-status-line ls-tool-call">
<span className="ls-tool-name">{toolName}:</span>
<span className="ls-tool-args"> {args}</span>
{marker && (
<span className={marker === '[OK]' ? 'ls-marker-ok' : 'ls-marker-err'}> {marker}</span>
)}
</div>
);
}
// Thinking text
return (
<div className="ls-status-line ls-thinking">
<span className="ls-thinking-prefix">{'\u2502'} </span>
<span className="ls-thinking-text">{line}</span>
</div>
);
};
export default StatusLine;

View File

@@ -0,0 +1,46 @@
import React, { useRef, useEffect, useState } from 'react';
import StatusLine from './status_line';
interface TerminalViewProps {
lines: string[];
maxLines?: number;
}
/**
* Terminal-style scrolling container for status lines.
* Auto-scrolls to bottom on new content unless user has scrolled up.
*/
const TerminalView: React.FC<TerminalViewProps> = ({ lines, maxLines = 30 }) => {
const containerRef = useRef<HTMLDivElement>(null);
const [userScrolled, setUserScrolled] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when new lines arrive (unless user scrolled up)
useEffect(() => {
if (!userScrolled && bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [lines.length, userScrolled]);
// Detect user scroll
const handleScroll = () => {
if (!containerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 20;
setUserScrolled(!isAtBottom);
};
// Show only the most recent lines
const visibleLines = lines.slice(-maxLines);
return (
<div className="ls-terminal" ref={containerRef} onScroll={handleScroll}>
{visibleLines.map((line, i) => (
<StatusLine key={i} line={line} />
))}
<div ref={bottomRef} />
</div>
);
};
export default TerminalView;

View File

@@ -0,0 +1,57 @@
import { PluginRegistry, WebSocketPayload, LiveStatusData } from './types';
import LiveStatusPost from './components/live_status_post';
import './styles/live_status.css';
const PLUGIN_ID = 'com.openclaw.livestatus';
const WS_EVENT = `custom_${PLUGIN_ID}_update`;
class LiveStatusPlugin {
private postTypeComponentId: string | null = null;
initialize(registry: PluginRegistry, store: any): void {
// Register custom post type renderer
this.postTypeComponentId = registry.registerPostTypeComponent(
'custom_livestatus',
LiveStatusPost,
);
// Register WebSocket event handler
registry.registerWebSocketEventHandler(WS_EVENT, (msg: any) => {
const data = msg.data as WebSocketPayload;
if (!data || !data.post_id) return;
// Update global store
const update: LiveStatusData = {
session_key: data.session_key,
post_id: data.post_id,
agent_id: data.agent_id,
status: data.status as LiveStatusData['status'],
lines: data.lines || [],
elapsed_ms: data.elapsed_ms || 0,
token_count: data.token_count || 0,
children: data.children || [],
start_time_ms: data.start_time_ms || 0,
};
window.__livestatus_updates[data.post_id] = update;
// Notify listeners
const listeners = window.__livestatus_listeners[data.post_id];
if (listeners) {
listeners.forEach((fn) => fn(update));
}
});
}
uninitialize(): void {
// Cleanup handled by Mattermost plugin framework
}
}
// Initialize global stores
if (typeof window !== 'undefined') {
window.__livestatus_updates = window.__livestatus_updates || {};
window.__livestatus_listeners = window.__livestatus_listeners || {};
}
(window as any).registerPlugin(PLUGIN_ID, new LiveStatusPlugin());

View File

@@ -0,0 +1,197 @@
/* OpenClaw Live Status — Post Type Styles */
.ls-post {
border-radius: 4px;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 4px 0;
}
/* Header */
.ls-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--center-channel-bg, #fff);
border-bottom: 1px solid var(--center-channel-color-08, rgba(0, 0, 0, 0.08));
}
.ls-agent-badge {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
background: var(--button-bg, #166de0);
color: var(--button-color, #fff);
}
.ls-child-badge {
font-size: 11px;
background: var(--center-channel-color-24, rgba(0, 0, 0, 0.24));
}
.ls-status-badge {
font-size: 11px;
font-weight: 600;
padding: 2px 6px;
border-radius: 3px;
text-transform: uppercase;
}
.ls-status-active .ls-status-badge {
background: #e8f5e9;
color: #2e7d32;
}
.ls-status-done .ls-status-badge {
background: #e3f2fd;
color: #1565c0;
}
.ls-status-error .ls-status-badge {
background: #fbe9e7;
color: #c62828;
}
.ls-status-interrupted .ls-status-badge {
background: #fff3e0;
color: #e65100;
}
.ls-elapsed {
font-size: 12px;
color: var(--center-channel-color-56, rgba(0, 0, 0, 0.56));
margin-left: auto;
font-family: 'SFMono-Regular', Consolas, monospace;
}
/* Live dot — pulsing green indicator */
.ls-live-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #4caf50;
animation: ls-pulse 1.5s ease-in-out infinite;
}
@keyframes ls-pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(0.8);
}
}
/* Terminal view */
.ls-terminal {
max-height: 400px;
overflow-y: auto;
padding: 8px 12px;
background: var(--center-channel-color-04, rgba(0, 0, 0, 0.04));
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
line-height: 1.6;
}
/* Status lines */
.ls-status-line {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ls-tool-call {
color: var(--center-channel-color, #3d3c40);
}
.ls-tool-name {
color: var(--link-color, #2389d7);
font-weight: 600;
}
.ls-tool-args {
color: var(--center-channel-color-72, rgba(0, 0, 0, 0.72));
}
.ls-marker-ok {
color: #4caf50;
font-weight: 600;
}
.ls-marker-err {
color: #f44336;
font-weight: 600;
}
.ls-thinking {
color: var(--center-channel-color-48, rgba(0, 0, 0, 0.48));
}
.ls-thinking-prefix {
color: var(--center-channel-color-24, rgba(0, 0, 0, 0.24));
}
/* Children (sub-agents) */
.ls-children {
border-top: 1px solid var(--center-channel-color-08, rgba(0, 0, 0, 0.08));
padding: 4px 0;
}
.ls-child {
margin: 0 12px;
border-left: 2px solid var(--center-channel-color-16, rgba(0, 0, 0, 0.16));
padding-left: 8px;
}
.ls-child-header {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 0;
font-size: 12px;
}
.ls-expand-icon {
font-size: 10px;
color: var(--center-channel-color-48, rgba(0, 0, 0, 0.48));
width: 12px;
}
/* Footer */
.ls-footer {
padding: 4px 12px 8px;
font-size: 11px;
color: var(--center-channel-color-48, rgba(0, 0, 0, 0.48));
font-family: 'SFMono-Regular', Consolas, monospace;
}
/* Loading state */
.ls-loading {
padding: 12px;
color: var(--center-channel-color-48, rgba(0, 0, 0, 0.48));
font-style: italic;
}
/* Scrollbar styling */
.ls-terminal::-webkit-scrollbar {
width: 6px;
}
.ls-terminal::-webkit-scrollbar-track {
background: transparent;
}
.ls-terminal::-webkit-scrollbar-thumb {
background: var(--center-channel-color-16, rgba(0, 0, 0, 0.16));
border-radius: 3px;
}
.ls-terminal::-webkit-scrollbar-thumb:hover {
background: var(--center-channel-color-32, rgba(0, 0, 0, 0.32));
}

View File

@@ -0,0 +1,39 @@
export interface LiveStatusData {
session_key: string;
post_id: string;
agent_id: string;
status: 'active' | 'done' | 'error' | 'interrupted';
lines: string[];
elapsed_ms: number;
token_count: number;
children: LiveStatusData[];
start_time_ms: number;
}
export interface WebSocketPayload {
post_id: string;
session_key: string;
agent_id: string;
status: string;
lines: string[];
elapsed_ms: number;
token_count: number;
children: LiveStatusData[];
start_time_ms: number;
}
// Mattermost plugin registry types (subset)
export interface PluginRegistry {
registerPostTypeComponent(typeName: string, component: any): string;
registerWebSocketEventHandler(event: string, handler: (msg: any) => void): void;
registerReducer(reducer: any): void;
unregisterComponent(componentId: string): void;
}
export interface Post {
id: string;
type: string;
props: Record<string, any>;
channel_id: string;
root_id: string;
}

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES6",
"module": "ESNext",
"moduleResolution": "node",
"jsx": "react",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"declaration": false,
"sourceMap": false,
"lib": ["ES6", "DOM"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,32 @@
var path = require('path');
module.exports = {
entry: './src/index.tsx',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'main.js',
library: {
type: 'umd',
},
},
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx'],
},
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
],
},
externals: {
react: 'React',
'react-dom': 'ReactDOM',
},
};

View File

@@ -276,7 +276,10 @@ class SessionMonitor extends EventEmitter {
} catch (_e) { } catch (_e) {
// File doesn't exist — skip silently // File doesn't exist — skip silently
if (this.logger) { if (this.logger) {
this.logger.debug({ sessionKey, transcriptFile }, 'Skipping session (transcript not found)'); this.logger.debug(
{ sessionKey, transcriptFile },
'Skipping session (transcript not found)',
);
} }
return; return;
} }

View File

@@ -76,10 +76,6 @@ class StatusBox extends EventEmitter {
// Throttle state per postId // Throttle state per postId
// Map<postId, { pending: string|null, timer: NodeJS.Timeout|null, lastFiredAt: number }> // Map<postId, { pending: string|null, timer: NodeJS.Timeout|null, lastFiredAt: number }>
this._throttleState = new Map(); this._throttleState = new Map();
// Track post metadata for delete+recreate
// Map<postId, { channelId, rootId }>
this._postInfo = new Map();
} }
/** /**
@@ -97,9 +93,6 @@ class StatusBox extends EventEmitter {
if (this.logger) this.logger.debug({ postId: post.id, channelId }, 'Created status post'); if (this.logger) this.logger.debug({ postId: post.id, channelId }, 'Created status post');
this.metrics.updatesSent++; this.metrics.updatesSent++;
// Track post info for delete+recreate
this._postInfo.set(post.id, { channelId, rootId: rootId || null });
return post.id; return post.id;
} }
@@ -145,6 +138,9 @@ class StatusBox extends EventEmitter {
/** /**
* Flush the pending update for a postId. * Flush the pending update for a postId.
* @private * @private
* Note: PUT updates cause Mattermost to show '(edited)' label on the post.
* This is a known API limitation. The Mattermost plugin (Phase 3) solves this
* via custom post type rendering.
*/ */
async _flushUpdate(postId) { async _flushUpdate(postId) {
const state = this._throttleState.get(postId); const state = this._throttleState.get(postId);
@@ -163,7 +159,7 @@ class StatusBox extends EventEmitter {
this.metrics.queueDepth = Math.max(0, this.metrics.queueDepth - 1); this.metrics.queueDepth = Math.max(0, this.metrics.queueDepth - 1);
try { try {
// In-place PUT update — no delete+recreate, no flicker // In-place PUT update
await this._apiCallWithRetry('PUT', `/api/v4/posts/${postId}`, { await this._apiCallWithRetry('PUT', `/api/v4/posts/${postId}`, {
id: postId, id: postId,
message: this._truncate(text), message: this._truncate(text),
@@ -202,15 +198,6 @@ class StatusBox extends EventEmitter {
await Promise.allSettled(postIds.map((id) => this.forceFlush(id))); await Promise.allSettled(postIds.map((id) => this.forceFlush(id)));
} }
/**
* Delete a post.
* @param {string} postId
* @returns {Promise<void>}
*/
async deletePost(postId) {
await this._apiCall('DELETE', `/api/v4/posts/${postId}`, null);
}
/** /**
* Truncate text to maxMessageChars. * Truncate text to maxMessageChars.
* @private * @private

View File

@@ -70,7 +70,12 @@ function format(sessionState, opts = {}) {
// never collapses like code blocks do, supports inline markdown // never collapses like code blocks do, supports inline markdown
var body = lines.join('\n'); var body = lines.join('\n');
if (depth === 0) { if (depth === 0) {
body = body.split('\n').map(function (l) { return '> ' + l; }).join('\n'); body = body
.split('\n')
.map(function (l) {
return '> ' + l;
})
.join('\n');
} }
return body; return body;
} }

View File

@@ -52,9 +52,12 @@ class StatusWatcher extends EventEmitter {
if (this.sessions.has(sessionKey)) return; if (this.sessions.has(sessionKey)) return;
// For new sessions (no saved offset), start from current file position // For new sessions (no saved offset), start from current file position
// so we only show NEW content going forward — not the entire backlog // so we only show NEW content going forward — not the entire backlog.
let startOffset = initialState.lastOffset || 0; // Pass lastOffset: 0 explicitly to read from the beginning.
if (!initialState.lastOffset) { var startOffset;
if (initialState.lastOffset !== undefined) {
startOffset = initialState.lastOffset;
} else {
try { try {
var stat = fs.statSync(transcriptFile); var stat = fs.statSync(transcriptFile);
startOffset = stat.size; startOffset = stat.size;
@@ -86,8 +89,8 @@ class StatusWatcher extends EventEmitter {
this.logger.debug({ sessionKey, transcriptFile, startOffset }, 'Session added to watcher'); this.logger.debug({ sessionKey, transcriptFile, startOffset }, 'Session added to watcher');
} }
// Only read if we have a saved offset (recovery) — new sessions start streaming from current position // Only read if we have an explicit offset (recovery or explicit 0) — new sessions start streaming from current position
if (initialState.lastOffset) { if (initialState.lastOffset !== undefined) {
this._readFile(sessionKey, state); this._readFile(sessionKey, state);
} }
@@ -327,9 +330,7 @@ class StatusWatcher extends EventEmitter {
argStr = item.arguments.url.slice(0, 60); argStr = item.arguments.url.slice(0, 60);
} }
} }
var statusLine = argStr var statusLine = argStr ? toolName + ': ' + argStr : toolName + ': ' + label;
? toolName + ': ' + argStr
: toolName + ': ' + label;
state.lines.push(statusLine); state.lines.push(statusLine);
} else if (item.type === 'text' && item.text) { } else if (item.type === 'text' && item.text) {
var text = item.text.trim(); var text = item.text.trim();

View File

@@ -240,14 +240,6 @@ async function startDaemon() {
const initialText = buildInitialText(agentId, sessionKey); const initialText = buildInitialText(agentId, sessionKey);
postId = await sharedStatusBox.createPost(channelId, initialText, rootPostId); postId = await sharedStatusBox.createPost(channelId, initialText, rootPostId);
logger.info({ sessionKey, postId, channelId }, 'Created status box'); logger.info({ sessionKey, postId, channelId }, 'Created status box');
// Auto-pin the status post so it's always visible
try {
await sharedStatusBox._apiCall('POST', '/api/v4/posts/' + postId + '/pin', {});
logger.debug({ sessionKey, postId }, 'Status post pinned');
} catch (pinErr) {
logger.warn({ sessionKey, err: pinErr }, 'Failed to pin status post');
}
} catch (err) { } catch (err) {
logger.error({ sessionKey, err }, 'Failed to create status post'); logger.error({ sessionKey, err }, 'Failed to create status post');
globalMetrics.lastError = err.message; globalMetrics.lastError = err.message;
@@ -339,14 +331,6 @@ async function startDaemon() {
logger.error({ sessionKey, err }, 'Failed to update final status'); logger.error({ sessionKey, err }, 'Failed to update final status');
} }
// Unpin the status post when session is done
try {
await sharedStatusBox._apiCall('POST', `/api/v4/posts/${box.postId}/unpin`, {});
logger.debug({ sessionKey, postId: box.postId }, 'Status post unpinned');
} catch (unpinErr) {
logger.warn({ sessionKey, err: unpinErr }, 'Failed to unpin status post');
}
// Clean up — remove from all tracking so session can be re-detected if it becomes active again // Clean up — remove from all tracking so session can be re-detected if it becomes active again
activeBoxes.delete(sessionKey); activeBoxes.delete(sessionKey);
watcher.removeSession(sessionKey); watcher.removeSession(sessionKey);

View File

@@ -0,0 +1,77 @@
'use strict';
/**
* Integration test: poll-fallback
*
* Verifies that StatusWatcher detects file changes via the polling fallback
* (not just fs.watch). Creates a temp JSONL file, initializes a StatusWatcher,
* appends a line, and asserts the 'session-update' event fires within 1000ms.
*/
var describe = require('node:test').describe;
var it = require('node:test').it;
var beforeEach = require('node:test').beforeEach;
var afterEach = require('node:test').afterEach;
var assert = require('node:assert/strict');
var fs = require('fs');
var path = require('path');
var os = require('os');
var StatusWatcher = require('../../src/status-watcher').StatusWatcher;
function createTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'poll-fallback-test-'));
}
describe('StatusWatcher poll fallback', function () {
var tmpDir;
var watcher;
beforeEach(function () {
tmpDir = createTmpDir();
});
afterEach(function () {
if (watcher) {
watcher.stop();
watcher = null;
}
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch (_e) {
/* ignore */
}
});
it('emits session-update within 1000ms when a line is appended to the JSONL file', function (t, done) {
var transcriptFile = path.join(tmpDir, 'test-session.jsonl');
fs.writeFileSync(transcriptFile, '');
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
// Use a saved offset of 0 so we read from beginning
watcher.addSession('test:poll', transcriptFile, { lastOffset: 0 });
var timer = setTimeout(function () {
done(new Error('session-update event not received within 1000ms'));
}, 1000);
watcher.once('session-update', function (sessionKey) {
clearTimeout(timer);
assert.equal(sessionKey, 'test:poll');
done();
});
// Append a valid JSONL line after a short delay to allow watcher setup
setTimeout(function () {
var record = {
type: 'message',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'Poll fallback test line' }],
},
};
fs.appendFileSync(transcriptFile, JSON.stringify(record) + '\n');
}, 100);
});
});

View File

@@ -21,6 +21,17 @@ function writeSessionsJson(dir, agentId, sessions) {
const agentDir = path.join(dir, agentId, 'sessions'); const agentDir = path.join(dir, agentId, 'sessions');
fs.mkdirSync(agentDir, { recursive: true }); fs.mkdirSync(agentDir, { recursive: true });
fs.writeFileSync(path.join(agentDir, 'sessions.json'), JSON.stringify(sessions, null, 2)); fs.writeFileSync(path.join(agentDir, 'sessions.json'), JSON.stringify(sessions, null, 2));
// Create stub transcript files so SessionMonitor's stale-check doesn't skip them
for (const key of Object.keys(sessions)) {
const entry = sessions[key]; // eslint-disable-line security/detect-object-injection
const sessionId = entry.sessionId || entry.uuid;
if (sessionId) {
const transcriptPath = path.join(agentDir, `${sessionId}.jsonl`);
if (!fs.existsSync(transcriptPath)) {
fs.writeFileSync(transcriptPath, '');
}
}
}
} }
function sleep(ms) { function sleep(ms) {

View File

@@ -86,7 +86,8 @@ describe('StatusWatcher', () => {
appendLine(file, { type: 'tool_call', name: 'exec', id: '1' }); appendLine(file, { type: 'tool_call', name: 'exec', id: '1' });
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 }); watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
watcher.addSession('test:key', file); // lastOffset: 0 explicitly reads from beginning (no lastOffset = skip to end)
watcher.addSession('test:key', file, { lastOffset: 0 });
const state = watcher.getSessionState('test:key'); const state = watcher.getSessionState('test:key');
assert.ok(state.lines.length > 0); assert.ok(state.lines.length > 0);
@@ -119,7 +120,7 @@ describe('StatusWatcher', () => {
appendLine(file, { type: 'tool_result', name: 'exec', id: '1' }); appendLine(file, { type: 'tool_result', name: 'exec', id: '1' });
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 }); watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
watcher.addSession('test:key', file); watcher.addSession('test:key', file, { lastOffset: 0 });
const state = watcher.getSessionState('test:key'); const state = watcher.getSessionState('test:key');
assert.equal(state.pendingToolCalls, 0); assert.equal(state.pendingToolCalls, 0);
@@ -137,7 +138,7 @@ describe('StatusWatcher', () => {
appendLine(file, { type: 'tool_result', name: 'exec', id: '1' }); appendLine(file, { type: 'tool_result', name: 'exec', id: '1' });
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 }); watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
watcher.addSession('test:key', file); watcher.addSession('test:key', file, { lastOffset: 0 });
const state = watcher.getSessionState('test:key'); const state = watcher.getSessionState('test:key');
assert.equal(state.pendingToolCalls, 1); // Read still pending assert.equal(state.pendingToolCalls, 1); // Read still pending
@@ -149,7 +150,7 @@ describe('StatusWatcher', () => {
appendLine(file, { type: 'usage', input_tokens: 1000, output_tokens: 500 }); appendLine(file, { type: 'usage', input_tokens: 1000, output_tokens: 500 });
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 }); watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
watcher.addSession('test:key', file); watcher.addSession('test:key', file, { lastOffset: 0 });
const state = watcher.getSessionState('test:key'); const state = watcher.getSessionState('test:key');
assert.equal(state.tokenCount, 1500); assert.equal(state.tokenCount, 1500);
@@ -195,7 +196,7 @@ describe('StatusWatcher', () => {
// Should not throw // Should not throw
assert.doesNotThrow(() => { assert.doesNotThrow(() => {
watcher.addSession('test:key', file); watcher.addSession('test:key', file, { lastOffset: 0 });
}); });
const state = watcher.getSessionState('test:key'); const state = watcher.getSessionState('test:key');
@@ -211,7 +212,8 @@ describe('StatusWatcher', () => {
appendLine(file, { type: 'assistant', text: 'Starting...' }); appendLine(file, { type: 'assistant', text: 'Starting...' });
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 0.2 }); // 200ms watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 0.2 }); // 200ms
watcher.addSession('test:key', file); // lastOffset: 0 so the pre-written content is read and idle timer is scheduled
watcher.addSession('test:key', file, { lastOffset: 0 });
const idle = []; const idle = [];
watcher.on('session-idle', (key) => idle.push(key)); watcher.on('session-idle', (key) => idle.push(key));

View File

@@ -25,9 +25,20 @@ function createTmpDir() {
} }
function writeSessionsJson(dir, agentId, sessions) { function writeSessionsJson(dir, agentId, sessions) {
const agentDir = path.join(dir, agentId, 'sessions'); var agentDir = path.join(dir, agentId, 'sessions');
fs.mkdirSync(agentDir, { recursive: true }); fs.mkdirSync(agentDir, { recursive: true });
fs.writeFileSync(path.join(agentDir, 'sessions.json'), JSON.stringify(sessions, null, 2)); fs.writeFileSync(path.join(agentDir, 'sessions.json'), JSON.stringify(sessions, null, 2));
// Create stub transcript files so SessionMonitor doesn't skip them as missing
for (var key in sessions) {
var entry = sessions[key]; // eslint-disable-line security/detect-object-injection
var sessionId = entry.sessionId || entry.uuid;
if (sessionId) {
var transcriptPath = path.join(agentDir, sessionId + '.jsonl');
if (!fs.existsSync(transcriptPath)) {
fs.writeFileSync(transcriptPath, '');
}
}
}
} }
function sleep(ms) { function sleep(ms) {
@@ -156,21 +167,22 @@ describe('Sub-Agent Support (Phase 3)', () => {
const result = format(parentState); const result = format(parentState);
const lines = result.split('\n'); const lines = result.split('\n');
// Parent header at depth 0 // Parent header at depth 0 — wrapped in blockquote ("> ")
assert.ok(lines[0].startsWith('[ACTIVE]')); assert.ok(lines[0].includes('[ACTIVE]'));
assert.ok(lines[0].includes('main')); assert.ok(lines[0].includes('main'));
// Child header at depth 1 (indented) // Child header at depth 1 (indented with "> " + 2 spaces)
const childHeaderLine = lines.find((l) => l.includes('proj035-planner')); const childHeaderLine = lines.find((l) => l.includes('proj035-planner'));
assert.ok(childHeaderLine); assert.ok(childHeaderLine);
assert.ok(childHeaderLine.startsWith(' ')); // indented by 2 spaces // Child lines appear as "> ..." (blockquote + 2-space indent)
assert.ok(/^> {2}/.test(childHeaderLine));
// Child should have [DONE] line // Child should have [DONE] line
assert.ok(result.includes('[DONE]')); assert.ok(result.includes('[DONE]'));
// Parent should not have [DONE] footer (still active) // Parent should not have [DONE] footer (still active)
// The top-level [DONE] is from child, not parent // The top-level [DONE] is from child, wrapped in blockquote
const parentDoneLines = lines.filter((l) => /^(?!\s)\[DONE\]/.test(l)); const parentDoneLines = lines.filter((l) => /^\[DONE\]/.test(l));
assert.equal(parentDoneLines.length, 0); assert.equal(parentDoneLines.length, 0);
}); });
@@ -248,8 +260,8 @@ describe('Sub-Agent Support (Phase 3)', () => {
const grandchildLine = lines.find((l) => l.includes('grandchild-agent')); const grandchildLine = lines.find((l) => l.includes('grandchild-agent'));
assert.ok(grandchildLine); assert.ok(grandchildLine);
// Grandchild at depth 2 should have 4 spaces of indent // Grandchild at depth 2 should have 4 spaces of indent after blockquote prefix ("> ")
assert.ok(grandchildLine.startsWith(' ')); assert.ok(/^> {4}/.test(grandchildLine));
}); });
}); });
@@ -266,8 +278,8 @@ describe('Sub-Agent Support (Phase 3)', () => {
const watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 }); const watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
try { try {
watcher.addSession('parent:key', parentFile, { depth: 0 }); watcher.addSession('parent:key', parentFile, { depth: 0, lastOffset: 0 });
watcher.addSession('child:key', childFile, { depth: 1 }); watcher.addSession('child:key', childFile, { depth: 1, lastOffset: 0 });
const parentState = watcher.getSessionState('parent:key'); const parentState = watcher.getSessionState('parent:key');
const childState = watcher.getSessionState('child:key'); const childState = watcher.getSessionState('child:key');
@@ -296,8 +308,8 @@ describe('Sub-Agent Support (Phase 3)', () => {
watcher.on('session-idle', (key) => idleEvents.push(key)); watcher.on('session-idle', (key) => idleEvents.push(key));
try { try {
watcher.addSession('parent2:key', parentFile, { depth: 0 }); watcher.addSession('parent2:key', parentFile, { depth: 0, lastOffset: 0 });
watcher.addSession('child2:key', childFile, { depth: 1 }); watcher.addSession('child2:key', childFile, { depth: 1, lastOffset: 0 });
await sleep(600); await sleep(600);
@@ -321,7 +333,7 @@ describe('Sub-Agent Support (Phase 3)', () => {
const watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 }); const watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
try { try {
watcher.addSession('subagent:key', file, { agentId: 'planner', depth: 1 }); watcher.addSession('subagent:key', file, { agentId: 'planner', depth: 1, lastOffset: 0 });
const state = watcher.getSessionState('subagent:key'); const state = watcher.getSessionState('subagent:key');
assert.ok(state.lines.some((l) => l.includes('Reading PLAN.md'))); assert.ok(state.lines.some((l) => l.includes('Reading PLAN.md')));
@@ -342,7 +354,7 @@ describe('Sub-Agent Support (Phase 3)', () => {
const watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 }); const watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
try { try {
watcher.addSession('err:key', file); watcher.addSession('err:key', file, { lastOffset: 0 });
const state = watcher.getSessionState('err:key'); const state = watcher.getSessionState('err:key');
const execLine = state.lines.find((l) => l.includes('exec')); const execLine = state.lines.find((l) => l.includes('exec'));

View File

@@ -108,9 +108,10 @@ describe('status-formatter.js', () => {
const result = format(parent); const result = format(parent);
assert.ok(result.includes('proj035-planner')); assert.ok(result.includes('proj035-planner'));
assert.ok(result.includes('Reading protocol...')); assert.ok(result.includes('Reading protocol...'));
// Child should be indented // Child should be indented — top-level uses blockquote prefix ("> ") so child
// lines appear as "> ..." ("> " + depth*2 spaces). Verify indentation exists.
const childLine = result.split('\n').find((l) => l.includes('proj035-planner')); const childLine = result.split('\n').find((l) => l.includes('proj035-planner'));
assert.ok(childLine && childLine.startsWith(' ')); assert.ok(childLine && /^> {2}/.test(childLine));
}); });
it('active session has no done footer', () => { it('active session has no done footer', () => {