49 Commits

Author SHA1 Message Date
Xen
f4a66f9644 v5.0: fix TRANSCRIPT_DIR default, disable custom post type
Root cause: TRANSCRIPT_DIR was /home/node/.openclaw/agents but the actual
sessions live at /root/.openclaw/agents. The daemon started, watched an empty
directory, and never detected any sessions.

Changes:
- config.js: default TRANSCRIPT_DIR -> /root/.openclaw/agents
- start-daemon.sh: same fix for fallback default
- .env.daemon (local): TRANSCRIPT_DIR fixed, PLUGIN_ENABLED=false

The custom_livestatus post type requires the Mattermost plugin webapp React
bundle to render. Disabled by default — now uses plain REST API posts with
markdown formatting, which render reliably everywhere (desktop, mobile, web).

Previous version preserved as git tag v4.1.
2026-03-15 10:20:48 +00:00
Xen
07c6fd701b Revert "fix: ghost watch false-positive reactivation on trailing writes"
This reverts commit 5c4a665fb9.
2026-03-10 08:38:41 +00:00
Xen
5c4a665fb9 fix: ghost watch false-positive reactivation on trailing writes
When the lock file is deleted (turn complete) and triggerIdle fires,
the transcript file continues receiving writes (the agent's own reply
being appended). The ghost watch was firing session-reactivate on these
trailing writes, causing an immediate complete→reactivate→complete loop
within the same turn.

Fix: only emit session-reactivate from ghost watch if the lock file
currently exists. A JSONL write without a lock file is a trailing write
from the completed turn, not a new user message.
2026-03-10 08:38:24 +00:00
Xen
f1d3ae9c4c fix: concurrent session-added dedup via sessionAddInProgress set
Root cause of double status boxes: lock file event + ghost watch both fire
at the same time on reactivation. Both call clearCompleted+pollNow, both
session-added events reach the handler before activeBoxes.has() returns true
for either, so two status boxes are created.

Fix: sessionAddInProgress Set gates the handler. First caller proceeds,
second caller sees the key in-progress and returns immediately. Cleared
on success (after activeBoxes.set) and on error (before return).
2026-03-09 21:13:18 +00:00
Xen
3fbd46c2d2 fix: dedup status boxes — one per channel, thread sessions take priority
When a bare channel session (no 🧵 suffix) and a thread-specific session
both resolve to the same MM channel/DM, two status boxes appeared simultaneously.

Fix: in session-added handler, before creating a box, check if any existing
active session already owns that channelId. Thread sessions displace bare channel
sessions (and take priority). Bare channel sessions are skipped if a thread
session already exists. First-in wins for same-type duplicates.
2026-03-09 21:10:41 +00:00
Xen
e483b0bc42 fix: skip heartbeat/internal sessions (agent:main:main) from status box creation
Heartbeat sessions (key pattern agent:<agent>:main) have no real Mattermost
conversation context. The daemon was resolving them to the DM fallback channel
and creating a new status box on every heartbeat cycle (~every 30min but firing
rapidly during active work). Each one appeared as a separate live status post.

Fix: in session-added handler, skip any session key matching /^agent:[^:]+:main$/
or /^agent:[^:]+:cli$/ before creating a status box.
2026-03-09 20:01:06 +00:00
Xen
888e8af784 fix(batch3): HTTP timeout, transient 429 handling, lock file poll fallback 2026-03-09 19:48:27 +00:00
Xen
897abf0a9a fix(batch2): JSONL line buffering, session-idle race guard, ghost watch deferred cleanup 2026-03-09 19:43:43 +00:00
Xen
0b39b39f3b fix(batch1): safe fixes — saveOffsets ordering, pid atomic, error handlers, go mutex, frontend cleanup 2026-03-09 19:43:30 +00:00
sol
cc65f9b5ce fix: clearCompleted must also delete from _knownSessions
Without this, _onSessionAdded never fires on reactivation because
isKnown=true short-circuits the new-session detection branch.

clearCompleted is called on lock file creation / ghost watch fire.
It clears _completedSessions cooldown but the session key stayed in
_knownSessions (isKnown=true), so poll() treated it as already tracked
and silently updated the entry instead of firing _onSessionAdded.

Fix: also delete from _knownSessions in clearCompleted() so next poll
sees the session as unknown and fires _onSessionAdded -> creates new
plugin status box.

Also: findExistingPost skipped in plugin mode on session-added to
prevent stale post reuse from REST search results.
2026-03-09 18:57:54 +00:00
sol
cdef7a1903 feat: lock file deletion = instant done signal
When the gateway deletes .jsonl.lock it means the final reply was sent.
Use this as an immediate 'turn complete' trigger instead of waiting for
cache-ttl (3s grace) or idle timeout (60s).

- status-watcher.js: _onFileChange checks if .lock file exists on event.
  If deleted -> emits 'session-lock-released'. Added triggerIdle() which
  cancels all timers and emits session-idle immediately.
- watcher-manager.js: wires session-lock-released/session-lock-released-path
  to watcher.triggerIdle() for instant completion.

Combined with lock-created trigger (previous commit), the full lifecycle is:
  User sends message
    -> .jsonl.lock created -> status box appears immediately
    -> JSONL writes -> status box updates in real time
    -> Gateway sends reply -> .jsonl.lock deleted -> status box marks done instantly
2026-03-09 18:49:26 +00:00
sol
0bdfaaa01d feat: lock file trigger for instant session activation
The gateway writes a .jsonl.lock file the instant it starts processing a
user message — before any JSONL content is written. This is the earliest
possible infrastructure signal that a session became active.

Previously the status box only appeared after the first JSONL write (first
assistant response token), meaning turns with no tool calls showed nothing.

Changes:
- status-watcher.js: _onFileChange handles .jsonl.lock events, emits
  'session-lock' (known session) or 'session-lock-path' (unknown session)
- watcher-manager.js: wires session-lock/session-lock-path to clearCompleted
  + pollNow for immediate reactivation from lock file event
- session-monitor.js: findSessionByFile() looks up session key by transcript
  path for lock events on sessions not yet in fileToSession map;
  _getAgentIds() helper for directory enumeration

Result: status box appears the moment the gateway receives the user message,
not when the first reply token is written.
2026-03-09 18:46:52 +00:00
sol
b0cb6db2a3 fix: stale offset reset + incremental _knownSessions tracking
src/status-watcher.js:
- When lastOffset > fileSize (stale offset from compaction or previous session),
  reset offset to current file end rather than 0.
  Resetting to 0 caused re-parsing gigabytes of old content; resetting to fileSize
  means we only read new bytes from this point forward. This was the root cause of
  the status box receiving no updates — the offset was past EOF so every read
  returned 0 bytes silently.

src/session-monitor.js:
- _knownSessions is now maintained incrementally instead of being replaced wholesale
  at the end of every poll cycle.
- Previously: _knownSessions = currentSessions at end of _poll() meant forgetSession()
  had no effect — the next poll immediately re-added the key as 'known' without firing
  _onSessionAdded, silently swallowing the reactivation.
- Now: entries are added/updated individually, removals delete from the map directly.
  forgetSession() + clearCompleted() + pollNow() now correctly triggers reactivation.

Verified: 3 consecutive 5s tests all show plugin KV updating with lines and timestamps.
2026-03-09 18:40:38 +00:00
sol
f545cb00be fix: orphan session cleanup + instant reactivation via ghost watch
plugin/server/store.go:
- CleanStaleSessions now handles last_update_ms=0 (pre-cleanup era orphans)
- Zero-timestamp sessions: mark active ones interrupted, delete non-active ones
- Previously these were silently skipped with 'continue', accumulating forever

src/status-watcher.js:
- removeSession() keeps fileToSession mapping as ghost entry ('\x00ghost:key')
- When ghost file changes, emits 'session-reactivate' immediately instead of
  waiting up to 2s for the session-monitor poll cycle
- Ghost removed after first trigger to avoid repeated events

src/session-monitor.js:
- Added pollNow() for immediate poll without waiting for interval tick
- Reactivation check now uses sessions.json updatedAt vs completedAt timestamp
  (pure infrastructure: two on-disk timestamps, no AI involvement)

src/watcher-manager.js:
- Wires session-reactivate event: clearCompleted() + pollNow() for instant re-detection
- New sessions now show up within ~100ms of first file change instead of 2s

Net result: status box appears reliably on every turn, clears 3s after reply,
zero orphan sessions accumulating in the KV store.
2026-03-09 18:31:41 +00:00
sol
3842adf562 fix: ghost-watch reactivation for consistent session restart
Problem: after a session completes, removeSession() deleted the file→session
mapping. When the next user message caused the JSONL to be written, fs.watch
fired but fileToSession returned undefined — silently dropped. Reactivation
only happened on the next session-monitor poll (up to 2s later), and by then
the watcher had missed the first lines of the new turn.

Fix:
- removeSession() keeps the file in fileToSession as a ghost marker
- fs.watch fires → ghost detected → emit 'session-reactivate'
- watcher-manager clears completedSessions cooldown + calls pollNow()
- session-monitor re-detects immediately with no poll lag
- Ghost removed after first fire (one-shot)

Also adds SessionMonitor.pollNow() for forced immediate polling.
2026-03-09 15:53:53 +00:00
sol
9a50bb9d55 fix: session reactivation and stale offset bugs
Bug 1: session-monitor suppressed reactivation for 5min (file staleness check)
Fix: compare sessions.json updatedAt against completedAt timestamp instead.
If updatedAt > completedAt, a new gateway turn started — reactivate immediately.

Bug 2: watcher-manager passed stale saved offset on reactivation
The saved offset pointed near end-of-file from the prior session, so only
1-2 lines were read (usually just cache-ttl), triggering immediate fast-idle
with no content shown.
Fix: reactivated sessions always start from current file position (new content
only), same as brand-new sessions.

Result: after completing a turn, the next message correctly reactivates
the status box and streams tool calls/content in real time.
2026-03-09 15:53:14 +00:00
sol
b320bcf843 fix: reactivate session on new turn via sessions.json updatedAt
Previously completed sessions were suppressed for 5 minutes based on
JSONL file staleness. With fast-idle (cache-ttl detection), sessions
complete in ~3s — but the gateway immediately appends the next user
message, keeping the file 'fresh'. This blocked reactivation entirely.

Fix: compare sessions.json updatedAt against the completion timestamp.
If the gateway updated the session AFTER we marked it complete, a new
turn has started — reactivate immediately.

Pure infrastructure: timestamp comparison between two on-disk files.
No AI model state or memory involved.
2026-03-09 15:31:35 +00:00
sol
b7c5124081 fix: fast-idle session on openclaw.cache-ttl (turn complete signal)
Previously the daemon waited IDLE_TIMEOUT_S (60s) after the last file
change before marking a session complete. But the JSONL file is kept
open by the gateway indefinitely, so file inactivity was never reliable.

Fix: detect the 'openclaw.cache-ttl' custom record which the gateway
emits after every completed assistant turn. When pendingToolCalls == 0,
start a 3-second grace timer instead of the full 60s idle timeout.

Result: live status clears within ~3 seconds of the agent's final reply
instead of lingering for 60+ seconds (or indefinitely on active sessions).

Fixes: session stays 'active' long after work is done
2026-03-09 15:26:14 +00:00
sol
3e93e40109 docs: add v4.1.0 changelog, plugin deploy guide, and plugin Makefile
- README: document all 5 fixes from Issue #5 (floating widget, RHS panel
  refresh bug, browser auth fix, session cleanup goroutine, KV scan optimization)
- README: add full Mattermost Plugin section with build/deploy instructions,
  manual deploy path for servers with plugin uploads disabled, auth model docs
- plugin/Makefile: build/package/deploy/health targets for production deployment
  on any new OpenClaw+Mattermost server

Closes the documentation gap so any developer can deploy this from scratch.
2026-03-09 14:41:33 +00:00
sol
2d493d5c34 feat: add Mattermost session auth for browser requests
- Add dual auth path in ServeHTTP: shared secret (daemon) OR Mattermost session (browser)
- Read-only endpoints (GET /sessions, GET /health) accept either auth method
- Write endpoints (POST, PUT, DELETE) still require shared secret
- Browser requests authenticated via Mattermost-User-Id header (auto-injected by MM server)
- Unauthenticated requests now properly rejected with 401

Fixes: Issue #5 Phase 1 - RHS Panel auth fix
2026-03-09 14:19:39 +00:00
sol
79d5e82803 feat: RHS panel initial fetch, floating widget, session cleanup (#5)
Phase 1: Fix RHS panel to fetch existing sessions on mount
- Add initial API fetch in useAllStatusUpdates() hook
- Allow GET /sessions endpoint without shared secret auth
- RHS panel now shows sessions after page refresh

Phase 2: Floating widget component (registerRootComponent)
- New floating_widget.tsx with auto-show/hide behavior
- Draggable, collapsible to pulsing dot with session count
- Shows last 5 lines of most recent active session
- Position persisted to localStorage
- CSS styles using Mattermost theme variables

Phase 3: Session cleanup and KV optimization
- Add LastUpdateMs field to SessionData for staleness tracking
- Set LastUpdateMs on session create and update
- Add periodic cleanup goroutine (every 5 min)
- Stale active sessions (>30 min no update) marked interrupted
- Expired non-active sessions (>1 hr) deleted from KV
- Add ListAllSessions and keep ListActiveSessions as helper
- Add debug logging to daemon file polling

Closes #5
2026-03-09 14:15:04 +00:00
sol
9ec52a418d feat: RHS panel + channel header button + public icon
- Switched from registerAppBarComponent (not in MM 11.4 build) to
  registerChannelHeaderButtonAction + registerRightHandSidebarComponent
- Added public/icon.svg for channel header button
- Fixed store dispatch for RHS toggle action
- Plugin deployment permissions fix (uid 2000)
2026-03-08 20:13:08 +00:00
sol
f0a51ce411 feat: RHS panel — persistent Agent Status sidebar
Added a Right-Hand Sidebar (RHS) panel to the Mattermost plugin that
shows live agent activity in a dedicated, always-visible panel.

- New RHSPanel component with SessionCard views per active session
- registerAppBarComponent adds 'Agent Status' icon to toolbar
- Subscribes to WebSocket updates via global listener
- Shows active sessions with live elapsed time, tool calls, token count
- Shows recent completed sessions below active ones
- Responsive CSS matching Mattermost design system

The RHS panel solves the scroll-out-of-view problem: the status
dashboard stays visible regardless of chat scroll position.
2026-03-08 19:55:44 +00:00
sol
c36a048dbb fix: stale sessions permanently ignored + CLI missing custom post type
Two bugs fixed:

1. Session monitor stale session bug: Sessions that were stale on first
   poll got added to _knownSessions but never re-checked, even after
   their transcript became active. Now stale sessions are tracked
   separately in _staleSessions and re-checked on every poll cycle.

2. CLI live-status tool: create/update commands were creating plain text
   posts without the custom_livestatus post type or plugin props. The
   Mattermost webapp plugin only renders posts with type=custom_livestatus.
   Now all CLI commands set the correct post type and livestatus props.
2026-03-08 12:00:13 +00:00
sol
4d644e7a43 fix: prevent duplicate status boxes on session idle/reactivation cycle
- Added completedBoxes map to track idle sessions and their post IDs
- On session reactivation, reuse existing post instead of creating new one
- Fixed variable scoping bug (saved -> savedState) in session-added handler
- Root cause: idle -> forgetSession -> re-detect -> new post -> repeat

This was creating 10+ duplicate status boxes per session per hour.
2026-03-08 08:05:15 +00:00
sol
09441b34c1 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
2026-03-08 07:42:27 +00:00
sol
0d0e6e9d90 fix: resolve DM channel for agent:main:main sessions
The main agent session uses key 'agent:main:main' which doesn't
contain a channel ID. The session monitor now falls back to reading
deliveryContext/lastTo from sessions.json and resolves 'user:XXXX'
format via the Mattermost direct channel API.

Fixes: status watcher not tracking the main agent's active transcript
2026-03-07 22:35:40 +00:00
sol
7aebebf193 fix: plugin bot user + await plugin detection before session scan
- Add EnsureBotUser on plugin activate (fixes 'Unable to find user' error)
- Accept bot_user_id in create session request
- Await plugin health check before starting session monitor
  (prevents race where sessions detect before plugin flag is set)
- Plugin now creates custom_livestatus posts with proper bot user
2026-03-07 22:25:59 +00:00
sol
42755e73ad feat(phase6): docs, lint fixes, STATE.json update
- Fix lint errors in plugin-client.js (unused var, empty block)
- Update README with plugin architecture and env vars
- Update STATE.json to v4.1 IMPLEMENTATION_COMPLETE
- All 96 tests passing, 0 lint errors
2026-03-07 22:14:23 +00:00
sol
c724e57276 feat: Mattermost plugin + daemon integration (Phases 2-5)
Plugin (Go server + React webapp):
- Custom post type 'custom_livestatus' with terminal-style rendering
- WebSocket broadcasts for real-time updates (no PUT, no '(edited)')
- KV store for session persistence across reconnects
- Shared secret auth for daemon-to-plugin communication
- Auto-scroll terminal with user scroll override
- Collapsible sub-agent sections
- Theme-compatible CSS (light/dark)

Daemon integration:
- PluginClient for structured data push to plugin
- Auto-detection: GET /health on startup + periodic re-check
- Graceful fallback: if plugin unavailable, uses REST API (PUT)
- Per-session mode tracking: sessions created via plugin stay on plugin
- Mid-session fallback: if plugin update fails, auto-switch to REST

Plugin deployed and active on Mattermost v11.4.0.
2026-03-07 22:11:06 +00:00
sol
868574d939 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)
2026-03-07 20:31:32 +00:00
sol
cc485f0009 Switch from code block to blockquote format
Code blocks collapse after ~4 lines in Mattermost, requiring click
to expand. Blockquotes (> prefix) never collapse and show all content
inline with a distinct left border.

- Tool calls: inline code formatting (backtick tool name)
- Thinking text: box drawing prefix for visual distinction
- Header: bold status + code agent name
- All lines visible without clicking to expand
2026-03-07 19:23:13 +00:00
sol
d5989cfab8 Switch from delete+recreate to PUT in-place updates
- Removes flicker caused by delete+recreate pattern
- PUT updates modify post content in-place (smooth)
- Trade-off: Mattermost shows (edited) label, and PUT clears pin status
- Pin+PUT are incompatible in Mattermost API — every PUT clears is_pinned
- Fix pin API calls to use {} body instead of null
- Remove post-replaced event handler (no longer needed)
2026-03-07 19:19:44 +00:00
sol
b255283724 Wrap status output in code block for visual distinction
Status posts now render inside triple-backtick code blocks
so they look different from normal chat replies.
2026-03-07 19:13:40 +00:00
sol
bbafdaf2d8 fix: delete+recreate status post, file polling fallback
- StatusBox: delete+recreate instead of PUT to keep post at thread bottom
  (Mattermost clears pin on PUT and doesn't bump edited posts)
- StatusBox: extends EventEmitter, emits 'post-replaced' events
- StatusWatcher: 500ms file polling fallback (fs.watch unreliable on
  Docker bind mounts / overlay fs)
- WatcherManager: handles post-replaced events to update activeBoxes
- SessionMonitor: forgetSession() for idle session re-detection
2026-03-07 19:07:01 +00:00
sol
3a8532bb30 fix: re-detect sessions after idle cleanup
Added forgetSession() to SessionMonitor. When watcher marks a session
idle/done, it now clears the key from the monitor's known sessions map.
Next poll cycle re-detects the session if the transcript is still active,
creating a fresh status post.
2026-03-07 18:52:44 +00:00
sol
6d31d77567 fix: stream from current position, faster session detection (500ms)
- New sessions start from current file offset, not 0. Shows live
  thinking from the moment of detection, not a backlog dump.
- Session poll reduced from 2s to 500ms for faster pickup.
- Auto-pin with null body (MM pin API quirk).
2026-03-07 18:47:25 +00:00
sol
b5bde4ec20 fix: pin status posts, staleness filter, correct transcript parsing
- Auto-pin status posts on creation, unpin on session completion
- Skip stale sessions (>5min since last transcript write)
- Parse OpenClaw JSONL format (type:message with nested role/content)
- Handle timestamp-prefixed transcript filenames
2026-03-07 18:41:23 +00:00
sol
7c6c8a4432 fix: production deployment issues
1. session-monitor: handle timestamp-prefixed transcript filenames
   OpenClaw uses {ISO}_{sessionId}.jsonl — glob for *_{sessionId}.jsonl
   when direct path doesn't exist.

2. session-monitor: skip stale sessions (>5min since last transcript write)
   Prevents creating status boxes for every old session in sessions.json.

3. status-watcher: parse actual OpenClaw JSONL transcript format
   Records are {type:'message', message:{role,content:[{type,name,...}]}}
   not {type:'tool_call', name}. Now shows live tool calls with arguments
   and assistant thinking text.

4. handler.js: fix module.exports for OpenClaw hook loader
   Expects default export (function), not {handle: function}.

5. HOOK.md: add YAML frontmatter metadata for hook discovery.
2026-03-07 18:31:43 +00:00
sol
387998812c feat(phase6): v1 removal checklist + STATE.json completion
- docs/v1-removal-checklist.md: exact sections to remove from 6 AGENTS.md files
  (deferred: actual removal happens after 1h+ production verification)
- STATE.json: updated to IMPLEMENTATION_COMPLETE, phase 6, all test results,
  v1RemovalStatus: DOCUMENTED_PENDING_PRODUCTION_VERIFICATION
- make check: clean
2026-03-07 17:47:13 +00:00
sol
835faa0eab feat(phase5): polish + deployment
- skill/SKILL.md: rewritten to 9 lines — 'status is automatic'
- deploy-to-agents.sh: no AGENTS.md injection; deploys hook + npm install
- install.sh: clean install flow; prints required env vars
- deploy/status-watcher.service: systemd unit file
- deploy/Dockerfile: containerized deployment (node:22-alpine)
- src/live-status.js: deprecation warning + start-watcher/stop-watcher pass-through
- README.md: full docs (architecture, install, config, upgrade guide, troubleshooting)
- make check: 0 errors, 0 format issues
- npm test: 59 unit + 36 integration = 95 tests passing
2026-03-07 17:45:22 +00:00
sol
5bb36150c4 feat(phase4): add gateway:startup hook for auto-starting watcher daemon
- hooks/status-watcher-hook/HOOK.md — events: ["gateway:startup"], required env vars
- hooks/status-watcher-hook/handler.js — checks PID file, spawns watcher-manager.js detached
- Deployed hook to /home/node/.openclaw/workspace/hooks/status-watcher-hook/
- make check passes
2026-03-07 17:41:03 +00:00
sol
6df3278e91 feat: Phase 3 — sub-agent detection, nested status, cascade completion
Phase 3 (Sub-Agent Support):
- session-monitor.js: sub-agents always passed through (inherit parent channel)
- watcher-manager.js enhancements:
  - Pending sub-agent queue: child sessions that arrive before parent are queued
    and processed when parent is registered (no dropped sub-agents)
  - linkSubAgent(): extracted helper for clean parent-child linking
  - Cascade completion: parent stays active until all children complete
  - Sub-agents embedded in parent status post (no separate top-level post)
- status-formatter.js: recursive nested rendering at configurable depth

Integration tests - test/integration/sub-agent.test.js (9 tests):
  3.1 Sub-agent detection via spawnedBy (monitor level)
  3.2 Nested status rendering (depth indentation, multiple children, deep nesting)
  3.3 Cascade completion (pending tool call tracking across sessions)
  3.4 Sub-agent JSONL parsing (usage events, error tool results)

All 95 tests pass (59 unit + 36 integration). make check clean.
2026-03-07 17:36:11 +00:00
sol
e3bd6c52dd feat: Phase 2 — session monitor, lifecycle, watcher manager
Phase 2 (Session Monitor + Lifecycle):
- src/session-monitor.js: polls sessions.json every 2s for new/ended sessions
  - Detects agents via transcriptDir subdirectory scan
  - Resolves channelId/rootPostId from session key format
  - Emits session-added/session-removed events
  - Handles multi-agent environments
  - Falls back to defaultChannel for non-MM sessions
- src/watcher-manager.js: top-level orchestrator
  - Starts session-monitor, status-watcher, health-server
  - Creates/updates Mattermost status posts on session events
  - Sub-agent linking: children embedded in parent status
  - Offset persistence (save/restore lastOffset on restart)
  - Post recovery on restart (search channel history for marker)
  - SIGTERM/SIGINT graceful shutdown: mark all boxes interrupted
  - CLI: node watcher-manager.js start|stop|status
  - MAX_ACTIVE_SESSIONS enforcement

Integration tests:
- test/integration/session-monitor.test.js: 14 tests
  - Session detection, removal, multi-agent, malformed JSON handling
- test/integration/status-watcher.test.js: 13 tests
  - JSONL parsing, tool_call/result pairs, idle detection, offset recovery

All 86 tests pass (59 unit + 27 integration). make check clean.
2026-03-07 17:32:28 +00:00
sol
43cfebee96 feat: Phase 0+1 — repo sync, pino, lint fixes, core components
Phase 0:
- Synced latest live-status.js from workspace (9928 bytes)
- Fixed 43 lint issues: empty catch blocks, console statements
- Added pino dependency
- Created src/tool-labels.json with all known tool mappings
- make check passes

Phase 1 (Core Components):
- src/config.js: env-var config with validation, throws on missing required vars
- src/logger.js: pino singleton with child loggers, level validation
- src/circuit-breaker.js: CLOSED/OPEN/HALF_OPEN state machine with callbacks
- src/tool-labels.js: exact/prefix/regex tool->label resolver with external override
- src/status-box.js: Mattermost post manager (keepAlive, throttle, retry, circuit breaker)
- src/status-formatter.js: pure SessionState->text formatter (nested, compact)
- src/health.js: HTTP health endpoint + metrics
- src/status-watcher.js: JSONL file watcher (inotify, compaction detection, idle detection)

Tests:
- test/unit/config.test.js: 7 tests
- test/unit/circuit-breaker.test.js: 12 tests
- test/unit/logger.test.js: 5 tests
- test/unit/status-formatter.test.js: 20 tests
- test/unit/tool-labels.test.js: 15 tests

All 59 unit tests pass. make check clean.
2026-03-07 17:26:53 +00:00
sol
b3ec2c61db plan: production-grade PLAN.md v2 (revised architecture + audit + simulation) 2026-03-07 16:09:36 +00:00
sol
6ef50269b5 resolve: keep workspace versions of skill/SKILL.md and live-status.js 2026-03-07 15:41:59 +00:00
sol
fe81de308f plan: v4 implementation plan + discovery findings
PROJ-035 Live Status v4 - implementation plan created by planner subagent.

Discovery findings documented in discoveries/README.md covering:
- JSONL transcript format (confirmed v3 schema)
- Session keying patterns (subagent spawnedBy linking)
- Hook events available (gateway:startup confirmed)
- Mattermost API (no edit time limit)
- Current v1 failure modes

Audit: 32/32 PASS, Simulation: READY
2026-03-07 15:41:50 +00:00
sol
0480180b03 Merge pull request 'policies: add standard policy files' (#1) from policies/add-standard-files into master 2026-03-01 08:26:43 +01:00
65 changed files with 11742 additions and 210 deletions

3
.gitignore vendored
View File

@@ -1,3 +1,6 @@
node_modules/
coverage/
*.log
plugin/server/dist/
plugin/webapp/node_modules/
.env.daemon

View File

@@ -2,3 +2,4 @@ node_modules/
coverage/
dist/
package-lock.json
Makefile

View File

@@ -1,23 +1,27 @@
export NODE_ENV := development
export NODE_PATH := /usr/local/lib/node_modules
.PHONY: check install test lint fmt fmt-check secret-scan
.PHONY: check install test test-integration lint fmt fmt-check secret-scan
check: install lint fmt-check secret-scan test
check: install lint fmt-check secret-scan test test-integration
install:
npm install
test:
@echo "[SKIP] No tests found"
node --test test/unit/*.test.js
test-integration:
node --test test/integration/*.test.js
lint:
npx eslint .
eslint .
fmt:
npx prettier --write .
prettier --write .
fmt-check:
npx prettier --check .
prettier --check .
secret-scan:
bash tools/secret-scan.sh .

404
PLAN.md Normal file
View File

@@ -0,0 +1,404 @@
# Implementation Plan: Live Status v4 (Production-Grade)
> Generated: 2026-03-07 | Agent: planner:proj035-v2 | Status: DRAFT
> Revised: Incorporates production-grade changes from scalability/efficiency review (comment #11402)
## 1. Goal
Replace the broken agent-cooperative live-status system (v1) with a transparent infrastructure-level daemon that tails OpenClaw JSONL transcript files in real-time and auto-updates Mattermost status boxes — zero agent cooperation required. Sub-agents become visible. Final-response spam is eliminated. Sessions never lose state. A single multiplexed daemon handles all concurrent sessions efficiently.
## 2. Architecture
```
OpenClaw Gateway
Agent Sessions (main, coder-agent, sub-agents, hooks...)
-> writes {uuid}.jsonl as they work
status-watcher daemon (SINGLE PROCESS — not per-session)
-> fs.watch recursive on transcript directory (inotify, Node 22)
-> Multiplexes all active session transcripts
-> SessionState map: sessionKey -> { postId, lastOffset, pendingToolCalls, lines[] }
-> Shared HTTP connection pool (keep-alive, maxSockets=4)
-> Throttled Mattermost updates (leading edge + trailing flush, 500ms)
-> Bounded concurrency: max N active status boxes (configurable, default 20)
-> Structured JSON logging (pino)
-> Graceful shutdown (SIGTERM/SIGINT -> mark all boxes "interrupted")
-> Circuit breaker for Mattermost API failures
Sub-agent transcripts
-> Session key pattern: agent:{id}:subagent:{uuid}
-> Detected automatically by directory watcher
-> spawnedBy field in sessions.json links child to parent
-> Nested under parent status box automatically
sessions.json (runtime registry)
-> Maps session keys -> { sessionId, spawnedBy, spawnDepth, label, channel }
-> Polled every 2s for new sessions (supplement to directory watch)
Mattermost API (slack.solio.tech)
-> POST /api/v4/posts -- create status box
-> PUT /api/v4/posts/{id} -- update in-place (no edit time limit confirmed)
-> Shared http.Agent with keepAlive: true, maxSockets: 4
-> Circuit breaker: open after 5 failures, 30s cooldown, half-open probe
```
### Key Design Decisions (from discovery)
1. **Single multiplexed daemon vs per-session daemons.** Eliminates unbounded process spawning. One V8 heap, one connection pool, one point of control. Scales to 30+ concurrent sessions without linear process overhead.
2. **fs.watch recursive on transcript directory.** Node 22 on Linux uses inotify natively for recursive watch. One watch, all sessions. No polling fallback needed for the watch itself.
3. **Poll sessions.json every 2s.** fs.watch on JSON files is unreliable on Linux (writes may not trigger events). Poll to detect new sessions reliably.
4. **Smart idle detection via pendingToolCalls.** Do not use a naive 30s timeout. Track tool_call / tool_result pairs. Session is idle only when pendingToolCalls==0 AND no new lines for IDLE_TIMEOUT seconds (default 60s).
5. **Leading edge + trailing flush throttle.** First event fires immediately (responsiveness). Subsequent events batched. Guaranteed final flush when activity stops (no lost updates).
6. **Mattermost post edit is unlimited.** PostEditTimeLimit=-1 confirmed on this server. No workarounds needed.
7. **All config via environment variables.** No hardcoded tokens, no sed replacement during install. Clean, 12-factor-style config.
8. **pino for structured logging.** Fast, JSON output, leveled. Production-debuggable.
9. **Circuit breaker for Mattermost API.** Prevents cascading failures during Mattermost outages. Bounded retry queue (max 100 entries).
10. **JSONL format is stable.** Version 3 confirmed. Parser abstracts format behind interface for future-proofing.
## 3. Tech Stack
| Layer | Technology | Version | Reason |
| ------------------ | ------------------ | ------------- | ------------------------------------------------------- |
| Runtime | Node.js | 22.x (system) | Already installed; inotify recursive fs.watch supported |
| File watching | fs.watch recursive | built-in | inotify on Linux/Node22; efficient, no polling |
| Session discovery | setInterval poll | built-in | sessions.json polling for new session detection |
| HTTP client | http.Agent | built-in | keepAlive, maxSockets; no extra dependency |
| Structured logging | pino | ^9.x | Fast JSON logging; single new dependency |
| Config | process.env | built-in | 12-factor; validated at startup |
| Health check | http.createServer | built-in | Lightweight health endpoint |
| Process management | PID file + signals | built-in | Simple, no supervisor dependency |
**New npm dependencies:** `pino` only. Everything else uses Node.js built-ins.
## 4. Project Structure
```
MATTERMOST_OPENCLAW_LIVESTATUS/
├── src/
│ ├── status-watcher.js CREATE Multiplexed directory watcher + JSONL parser
│ ├── status-box.js CREATE Mattermost post manager (shared pool, throttle, circuit breaker)
│ ├── session-monitor.js CREATE Poll sessions.json for new/ended sessions
│ ├── tool-labels.js CREATE Pattern-matching tool name -> label resolver
│ ├── config.js CREATE Centralized env-var config with validation
│ ├── logger.js CREATE pino wrapper (structured JSON logging)
│ ├── circuit-breaker.js CREATE Circuit breaker for API resilience
│ ├── health.js CREATE HTTP health endpoint + metrics
│ ├── watcher-manager.js CREATE Entrypoint: orchestrates all above, PID file, graceful shutdown
│ ├── tool-labels.json CREATE Built-in tool label defaults
│ ├── live-status.js DEPRECATE Keep for backward compat; add deprecation warning
│ └── agent-accounts.json KEEP Agent ID -> bot account mapping
├── hooks/
│ └── status-watcher-hook/
│ ├── HOOK.md CREATE events: ["gateway:startup"]
│ └── handler.js CREATE Spawns watcher-manager on gateway start
├── deploy/
│ ├── status-watcher.service CREATE systemd unit file
│ └── Dockerfile CREATE Container deployment option
├── test/
│ ├── unit/ CREATE Unit tests (parser, tool-labels, circuit-breaker, throttle)
│ └── integration/ CREATE Integration tests (lifecycle, restart recovery, sub-agent)
├── skill/
│ └── SKILL.md REWRITE "Status is automatic, no action needed" (10 lines)
├── discoveries/
│ └── README.md EXISTING Discovery findings (do not overwrite)
├── deploy-to-agents.sh REWRITE Installs hook into workspace hooks dir; no AGENTS.md injection
├── install.sh REWRITE npm install + deploy hook + optional gateway restart
├── README.md REWRITE Full v4 documentation
├── package.json MODIFY Add pino dependency, test/start/stop/status scripts
└── Makefile MODIFY Update check/test/lint/fmt targets
```
## 5. Dependencies
| Package | Version | Purpose | New/Existing |
| ----------------------------- | -------- | ----------------------- | ----------------- |
| pino | ^9.x | Structured JSON logging | NEW |
| node.js | 22.x | Runtime | Existing (system) |
| http, fs, path, child_process | built-in | All other functionality | Existing |
One new npm dependency only. Minimal footprint.
## 6. Data Model
### sessions.json entry (relevant fields)
```json
{
"agent:main:subagent:uuid": {
"sessionId": "50dc13ad-...",
"sessionFile": "50dc13ad-....jsonl",
"spawnedBy": "agent:main:main",
"spawnDepth": 1,
"label": "proj035-planner",
"channel": "mattermost",
"groupChannel": "#channelId__botUserId"
}
}
```
### JSONL event schema
```
type=session -> id (UUID), version (3), cwd — first line only
type=message -> role=user|assistant|toolResult; content[]=text|toolCall|toolResult|thinking
type=custom -> customType=openclaw.cache-ttl (turn boundary marker)
type=model_change -> provider, modelId
```
### SessionState (in-memory per active session)
```json
{
"sessionKey": "agent:main:subagent:uuid",
"sessionFile": "/path/to/{uuid}.jsonl",
"bytesRead": 4096,
"statusPostId": "abc123def456",
"channelId": "yy8agcha...",
"rootPostId": null,
"startTime": 1772897576000,
"lastActivity": 1772897590000,
"pendingToolCalls": 0,
"lines": ["[15:21] Reading file... done", ...],
"subAgentKeys": ["agent:main:subagent:child-uuid"],
"parentSessionKey": null,
"complete": false
}
```
### Configuration (env vars)
```
MM_TOKEN (required) Mattermost bot token
MM_URL (required) Mattermost base URL
TRANSCRIPT_DIR (required) Path to agent sessions directory
SESSIONS_JSON (required) Path to sessions.json
THROTTLE_MS 500 Min interval between Mattermost updates
IDLE_TIMEOUT_S 60 Inactivity before marking session complete
MAX_SESSION_DURATION_S 1800 Hard timeout for any session (30 min)
MAX_STATUS_LINES 15 Max lines in status box (oldest dropped)
MAX_ACTIVE_SESSIONS 20 Bounded concurrency for status boxes
MAX_MESSAGE_CHARS 15000 Mattermost post truncation limit
HEALTH_PORT 9090 Health check HTTP port (0 = disabled)
LOG_LEVEL info Logging level
CIRCUIT_BREAKER_THRESHOLD 5 Consecutive failures to open circuit
CIRCUIT_BREAKER_COOLDOWN_S 30 Cooldown before half-open probe
PID_FILE /tmp/status-watcher.pid
TOOL_LABELS_FILE null Optional external tool labels JSON override
DEFAULT_CHANNEL null Fallback channel for non-MM sessions (null = skip)
```
### Status box format (rendered Mattermost text)
```
[ACTIVE] main | 38s
Reading live-status source code...
exec: ls /agents/sessions [OK]
Analyzing agent configurations...
exec: grep -r live-status [OK]
Writing new implementation...
Sub-agent: proj035-planner
Reading protocol...
Analyzing JSONL format...
[DONE] 28s
Plan ready. Awaiting approval.
[DONE] 53s | 12.4k tokens
```
## 7. Task Checklist
### Phase 0: Repo Sync + Environment Verification ⏱️ 30min
> Parallelizable: no | Dependencies: none
- [ ] 0.1: Sync workspace live-status.js (283-line v2) to remote repo — git push → remote matches workspace copy
- [ ] 0.2: Fix existing lint errors in live-status.js (43 issues: empty catch blocks, console statements) — replace empty catches with error logging, add eslint-disable comments for intentional console.log → make lint passes
- [ ] 0.3: Run `make check` — verify all Makefile targets pass (lint/fmt/test/secret-scan) → clean run, zero failures
- [ ] 0.4: Verify `pino` available via npm — add to package.json and `npm install` → confirm installs cleanly
- [ ] 0.5: Create `src/tool-labels.json` with initial tool->label mapping (all known tools from agent-accounts + TOOLS.md) → file exists, valid JSON
- [ ] 0.6: Document exact transcript directory path and sessions.json path from the running gateway → constants confirmed for config.js (transcript dir: /home/node/.openclaw/agents/{agent}/sessions/, sessions.json: same path)
### Phase 1: Core Components ⏱️ 8-12h
> Parallelizable: partially (config/logger/circuit-breaker are independent) | Dependencies: Phase 0
- [ ] 1.1: Create `src/config.js` — reads all env vars with validation; throws clear error on missing required vars; exports typed config object → unit testable, fails fast
- [ ] 1.2: Create `src/logger.js` — pino wrapper with default config (JSON output, leveled); singleton; session-scoped child loggers via `logger.child({sessionKey})` → used by all modules
- [ ] 1.3: Create `src/circuit-breaker.js` — state machine (closed/open/half-open), configurable threshold and cooldown, callbacks for state changes → unit tested with simulated failures
- [ ] 1.4: Create `src/tool-labels.js` — loads `tool-labels.json`; supports exact match, prefix match (e.g. `camofox_*`), regex match; default label "Working..."; configurable external override file → unit tested with 20+ tool names
- [ ] 1.5: Create `src/status-box.js` — Mattermost post manager:
- Shared `http.Agent` (keepAlive, maxSockets=4)
- `createPost(channelId, text, rootId?)` -> postId
- `updatePost(postId, text)` -> void
- Throttle: leading edge fires immediately, trailing flush after THROTTLE_MS; coalesce intermediate updates
- Message size guard: truncate to MAX_MESSAGE_CHARS
- Circuit breaker wrapping all API calls
- Retry with exponential backoff on 429/5xx (up to 3 retries)
- Structured logs for every API call
→ unit tested with mock HTTP server
- [ ] 1.6: Create `src/status-formatter.js` — pure function; input: SessionState; output: formatted Mattermost text string (compact, MAX_STATUS_LINES, sub-agent nesting, status prefix, timestamps) → unit tested with varied inputs
- [ ] 1.7: Create `src/health.js` — HTTP server on HEALTH_PORT; GET /health returns JSON {status, activeSessions, uptime, lastError, metrics: {updates_sent, updates_failed, circuit_state, queue_depth}} → manually tested with curl
- [ ] 1.8: Create `src/status-watcher.js` — core JSONL watcher:
- fs.watch on TRANSCRIPT_DIR (recursive)
- On file change event: determine which sessionKey owns the file (via filename->sessionKey map built from sessions.json)
- Read new bytes from lastOffset; split on newlines; parse JSONL
- Map parsed events to SessionState updates:
- toolCall -> increment pendingToolCalls, add status line
- toolResult -> decrement pendingToolCalls, update status line with result
- assistant text -> add status line (truncated to 80 chars)
- turn boundary (cache-ttl custom) -> flush status update
- Detect file truncation (stat.size < bytesRead) -> reset offset, log warning
- Debounce updates via status-box.js throttle
- Idle detection: when pendingToolCalls==0 and no new lines for IDLE_TIMEOUT_S
→ integration tested with real JSONL sample files
- [ ] 1.9: Unit test suite (`test/unit/`) — parser, tool-labels, circuit-breaker, throttle, status-formatter → `npm test` green
### Phase 2: Session Monitor + Lifecycle ⏱️ 4-6h
> Parallelizable: no | Dependencies: Phase 1
- [ ] 2.1: Create `src/session-monitor.js` — polls sessions.json every 2s:
- Diffs prev vs current to detect added/removed sessions
- Emits `session-added` with {sessionKey, sessionFile, spawnedBy, channelId, rootPostId}
- Emits `session-removed` with sessionKey
- Resolves channelId from session key (format: `agent:main:mattermost:channel:{id}:...`)
- Resolves rootPostId from session key (format: `...thread:{id}`)
- Falls back to DEFAULT_CHANNEL for non-MM sessions (or null to skip)
→ integration tested with mock sessions.json writes
- [ ] 2.2: Persist session offsets to disk — on each status update, write { sessionKey: bytesRead } to `/tmp/status-watcher-offsets.json`; on startup, load and restore existing sessions → restart recovery working
- [ ] 2.3: Post recovery on restart — on startup, for each restored session, search channel history for status post with marker comment `<!-- sw:{sessionKey} -->`; if found, resume updating it; if not, create new post → tested by killing and restarting daemon mid-session
- [ ] 2.4: Create `src/watcher-manager.js` — top-level orchestrator:
- Starts session-monitor and status-watcher
- On session-added: create SessionState, link to parent if spawnedBy set, add to status-watcher watch list
- On session-removed: schedule idle cleanup (allow final flush)
- Enforces MAX_ACTIVE_SESSIONS (drops lowest-priority session if over limit, logs warning)
- Writes/reads PID file
- Registers SIGTERM/SIGINT handlers:
- On signal: mark all active status boxes "interrupted", flush all pending updates, remove PID file, exit 0
- CLI: `node watcher-manager.js start|stop|status` → process management
→ smoke tested end-to-end
- [ ] 2.5: Integration test suite (`test/integration/`) — lifecycle events, restart recovery → `npm run test:integration` green
### Phase 3: Sub-Agent Support ⏱️ 3-4h
> Parallelizable: no | Dependencies: Phase 2
- [ ] 3.1: Sub-agent detection — session-monitor detects entries with `spawnedBy` field; links child SessionState to parent via `parentSessionKey` → linked correctly
- [ ] 3.2: Nested status rendering — status-formatter renders sub-agent lines as indented block under parent status; sub-agent summary: label + elapsed + final status → visible in Mattermost as nested
- [ ] 3.3: Cascade completion — parent session's idle detection checks that all child sessions are complete before marking parent done → no premature parent completion
- [ ] 3.4: Sub-agent status post reuse — sub-agents do not create new top-level posts; their status is embedded in the parent post body → only one post per parent session visible in channel
- [ ] 3.5: Integration test — spawn mock sub-agent transcript, verify parent status box shows nested child progress → manual verification in Mattermost
### Phase 4: Hook Integration ⏱️ 1h
> Parallelizable: no | Dependencies: Phase 2 (watcher-manager CLI working)
- [ ] 4.1: Create `hooks/status-watcher-hook/HOOK.md` — events: ["gateway:startup"], description, required env vars listed → OpenClaw discovers hook
- [ ] 4.2: Create `hooks/status-watcher-hook/handler.js` — on gateway:startup: check if watcher already running (PID file), if not: spawn `node watcher-manager.js start` as detached background process → watcher auto-starts with gateway
- [ ] 4.3: Deploy hook to workspace — `cp -r hooks/status-watcher-hook /home/node/.openclaw/workspace/hooks/` → hook in place
- [ ] 4.4: Test: gateway restart -> watcher starts, PID file written, health endpoint responds → verified
### Phase 5: Polish + Deployment ⏱️ 3-4h
> Parallelizable: yes (docs, deploy scripts, skill rewrite are independent) | Dependencies: Phase 4
- [ ] 5.1: Rewrite `skill/SKILL.md` — 10-line file: "Live status updates are automatic. You do not need to call live-status manually. Focus on your task." → no protocol injection
- [ ] 5.2: Rewrite `deploy-to-agents.sh` — remove AGENTS.md injection; deploy hook; npm install; optionally restart gateway → one-command deploy
- [ ] 5.3: Rewrite `install.sh` — npm install (installs pino); deploy hook; print post-install instructions including env vars required → clean install flow
- [ ] 5.4: Create `deploy/status-watcher.service` — systemd unit file for standalone deployment (non-hook mode); uses env file at `/etc/status-watcher.env` → usable with systemctl
- [ ] 5.5: Create `deploy/Dockerfile` — FROM node:22-alpine; COPY src/ test/; RUN npm install; CMD ["node", "watcher-manager.js", "start"] → containerized deployment option
- [ ] 5.6: Update `src/live-status.js` — add startup deprecation warning "NOTE: live-status CLI is deprecated as of v4. Status updates are now automatic."; add `start-watcher` and `stop-watcher` pass-through commands → backward compat maintained
- [ ] 5.7: Handle session compaction edge case — add test with truncated JSONL file; verify watcher resets offset and continues without crash → no data loss
- [ ] 5.8: Write `README.md` — architecture diagram (ASCII), install steps, config reference, upgrade guide from v1, troubleshooting → complete documentation
- [ ] 5.9: Run `make check` → zero lint/format errors; `npm test` → green
### Phase 6: Remove v1 Injection from AGENTS.md ⏱️ 30min
> Parallelizable: no | Dependencies: Phase 5 fully verified + watcher confirmed running
> SAFETY: Do not execute this phase until watcher has been running successfully for at least 1 hour
- [ ] 6.1: Verify watcher is running — check PID file, health endpoint, and at least one real status box update → confirmed working before touching AGENTS.md
- [ ] 6.2: Remove "Live Status Protocol (MANDATORY)" section from main AGENTS.md → section removed
- [ ] 6.3: Remove from all other agent AGENTS.md files (coder-agent, xen, global-calendar, nutrition-agent, gym-designer) → all cleaned up
- [ ] 6.4: Commit AGENTS.md changes with message "feat: remove v1 live-status injection (v4 watcher active)" → change tracked
## 8. Testing Strategy
| What | Type | How | Success Criteria |
| ------------------- | ----------- | --------------------------------------------------- | ------------------------------------------------------------- |
| config.js | Unit | Env var injection, missing var detection | Throws on missing required vars; correct defaults |
| logger.js | Unit | Log output format | JSON output, levels respected |
| circuit-breaker.js | Unit | Simulate N failures, verify state transitions | open after threshold, half-open after cooldown |
| tool-labels.js | Unit | 30+ tool names (exact, prefix, regex, unmapped) | Correct labels returned; default for unknown |
| status-formatter.js | Unit | Various SessionState inputs | Correct compact output; MAX_LINES enforced |
| status-box.js | Unit | Mock HTTP server | create/update called correctly; throttle works; circuit fires |
| session-monitor.js | Integration | Write test sessions.json; verify events emitted | session-added/removed within 2s |
| status-watcher.js | Integration | Append to JSONL file; verify Mattermost update | Update within 1.5s of new line |
| Idle detection | Integration | Stop writing; verify complete after IDLE_TIMEOUT+5s | Status box marked done |
| Session compaction | Integration | Truncate JSONL file mid-session | No crash; offset reset; no duplicate events |
| Restart recovery | Integration | Kill daemon mid-session; restart | Existing post updated, not new post created |
| Sub-agent nesting | Integration | Mock parent + child transcripts | Child visible in parent status box |
| Cascade completion | Integration | Child completes; verify parent waits | Parent marks done after last child |
| Health endpoint | Manual | curl localhost:9090/health | JSON with correct metrics |
| E2E smoke test | Manual | Real agent task in Mattermost | Real-time updates; no spam; done on completion |
## 9. Risks & Mitigations
| Risk | Impact | Mitigation |
| --------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------- |
| fs.watch recursive not reliable on this kernel | High | Detect at startup; fall back to polling if watch fails (setInterval 2s on directory listing) |
| sessions.json write race causes parse error | Medium | Try/catch on JSON.parse; retry next poll cycle; log warning |
| Mattermost rate limit (10 req/s default) | Medium | Throttle to max 2 req/s per session; circuit breaker; exponential backoff on 429 |
| Session compaction truncates JSONL | Medium | Detect stat.size < bytesRead on each read; reset offset; dedup by tracking last processed line index |
| Multiple gateway restarts create duplicate watchers | Medium | PID file check + SIGTERM old process before spawning new one |
| Non-MM sessions (hook, cron) generate noise | Low | Channel resolver returns null; watcher skips session gracefully |
| pino dependency unavailable | Low | If npm install fails, fallback to console.log (degrade gracefully, log warning) |
| Status box exceeds Mattermost post size limit | Low | Hard truncate at MAX_MESSAGE_CHARS (15000); tested with message size guard |
| JSONL format changes in future OpenClaw | Medium | Abstract parser behind EventParser interface; version check on session record |
| Daemon crashes mid-session | Medium | Health check via systemd/Docker; restart policy; offset persistence enables recovery |
## 10. Effort Estimate
| Phase | Time | Can Parallelize? | Depends On |
| -------------------------------------- | ---------- | ----------------------------------------- | ---------------- |
| Phase 0: Repo + Env Verification | 15min | No | — |
| Phase 1: Core Components | 8-12h | Partially (config/logger/circuit-breaker) | Phase 0 |
| Phase 2: Session Monitor + Lifecycle | 4-6h | No | Phase 1 |
| Phase 3: Sub-Agent Support | 3-4h | No | Phase 2 |
| Phase 4: Hook Integration | 1h | No | Phase 2+3 |
| Phase 5: Polish + Deployment | 3-4h | Yes (docs, deploy, skill) | Phase 4 |
| Phase 6: Remove v1 AGENTS.md Injection | 30min | No | Phase 5 verified |
| **Total** | **20-28h** | | |
## 11. Open Questions
All questions have defaults that allow execution to proceed without answers.
- [ ] **Q1 (informational): Idle timeout tuning.** 60s default may still cause premature completion for very long exec calls (e.g., a 3-minute build). Smart heuristic (pendingToolCalls tracking) should handle this correctly, but production data may reveal edge cases.
**Default:** Use smart heuristic (pendingToolCalls + IDLE_TIMEOUT_S=60). Log false-positives for tuning.
- [ ] **Q2 (informational): Non-MM session behavior.** Hook sessions, cron sessions, and xen sessions don't have a Mattermost channel. Currently skipped.
**Default:** Skip non-MM sessions (no status box). Log at debug level. Can revisit for Phase 7.
- [ ] **Q3 (informational): Status box per-request vs per-session.** Currently: one status box per user message (reset on new user turn). This is the most natural UX.
**Default:** Per-request. New user message starts new status cycle. Works correctly with smart idle detection.
- [ ] **Q4 (informational): Compaction dedup strategy.** When JSONL is truncated, we reset offset and re-read. We may re-process events already posted to Mattermost.
**Default:** Track last processed line count (not just byte offset). Skip lines already processed on re-read. OR: detect compaction and do not re-append old events (since they were already shown). Simplest: mark box as "session compacted - continuing" and reset the visible lines in the status box.
- [ ] **Q5 (blocking if no): AGENTS.md modification scope.** Phase 6 removes Live Status Protocol section from all agent AGENTS.md files. Confirm Rooh wants all instances removed (not just main agent).
**Default if not answered:** Remove from all agents. This is the stated goal — removing v1 injection everywhere.

397
README.md
View File

@@ -1,28 +1,391 @@
# OpenClaw Live Status Tool
# Live Status v4.1
A lightweight CLI tool for OpenClaw agents to provide "Antigravity-style" live status updates in Mattermost channels (and others) without spamming.
Real-time Mattermost progress updates for OpenClaw agent sessions.
## Features
Version 4 replaces the manual v1 live-status CLI with a transparent infrastructure daemon.
Agents no longer need to call `live-status`. The watcher auto-updates Mattermost as they work.
- **Live Updates:** Create a single message and update it repeatedly.
- **Sub-Agent Support:** Works in clean environments via embedded config or CLI flags.
- **Cross-Channel:** Supports dynamic channel targeting via `--channel`.
- **One-Click Install:** Updates binaries, skills, and agent protocols automatically.
## What's New in v4.1
## Installation
- **Floating widget** — PiP-style overlay using `registerRootComponent`. Auto-shows when a session starts, auto-hides when idle. Draggable, collapsible, position persisted to localStorage. Solves the "status box buried in long threads" problem without touching post ordering.
- **RHS panel fix** — Panel now loads existing sessions on mount (previously empty after page refresh). Added dual auth path so browser JS can fetch sessions without the daemon shared secret.
- **Session cleanup** — Orphaned sessions (daemon crash, etc.) now auto-expire: stale after 30 min inactivity, deleted after 1 hour.
- **KV prefix filter** — `ListActiveSessions` now filters at the KV level instead of scanning all plugin keys.
Run the interactive installer wizard:
## Architecture
```bash
./install.sh
Two rendering modes (auto-detected):
### Plugin Mode (preferred)
When the Mattermost plugin is installed, updates stream via WebSocket:
- Custom post type `custom_livestatus` with terminal-style React rendering
- Zero Mattermost post API calls during streaming (no "(edited)" label)
- Auto-scroll, collapsible sub-agents, theme-compatible
### REST API Fallback
When the plugin is unavailable, updates use the Mattermost REST API:
- Blockquote-formatted posts updated via PUT
- Shows "(edited)" label (Mattermost API limitation)
```
OpenClaw Gateway
Agent Sessions
-> writes {uuid}.jsonl as they run
status-watcher daemon (SINGLE PROCESS)
-> fs.watch + polling fallback on transcript directory
-> Multiplexes all active sessions
-> Auto-detects plugin (GET /health every 60s)
-> Plugin mode: POST/PUT/DELETE to plugin REST endpoint
-> Plugin broadcasts via WebSocket to React component
-> REST fallback: PUT to Mattermost post API
-> Shared HTTP connection pool (keep-alive, maxSockets=4)
-> Throttled updates (leading edge + trailing flush, 500ms)
-> Circuit breaker for API failure resilience
-> Graceful shutdown (SIGTERM -> mark all boxes "interrupted")
-> Sub-agent nesting (child sessions under parent status box)
Mattermost Plugin (com.openclaw.livestatus)
-> Go server: REST API + KV store + WebSocket broadcast
-> React webapp: custom post type renderer
-> Terminal-style UI with auto-scroll
gateway:startup hook
-> hooks/status-watcher-hook/handler.js
-> Checks PID file; spawns daemon if not running
Mattermost API (fallback)
-> PUT /api/v4/posts/{id} (in-place edits, unlimited)
-> Shared http.Agent (keepAlive, maxSockets=4)
-> Circuit breaker: open after 5 failures, 30s cooldown
```
## Usage
## Install
```bash
# Create a new status box
ID=$(live-status create "Initializing...")
### Prerequisites
# Update the status box
live-status update $ID "Working..."
- Node.js 22.x
- OpenClaw gateway running
- Mattermost bot token
### One-command install
```sh
cd /path/to/MATTERMOST_OPENCLAW_LIVESTATUS
bash install.sh
```
This installs npm dependencies and deploys the `gateway:startup` hook.
The daemon starts automatically on the next gateway restart.
### Manual start (without gateway restart)
Set required env vars, then:
```sh
node src/watcher-manager.js start
```
## Configuration
All config via environment variables. No hardcoded values.
### Required
| Variable | Description |
| ---------------- | ----------------------------------------------------- |
| `MM_TOKEN` | Mattermost bot token |
| `MM_URL` | Mattermost base URL (e.g. `https://slack.solio.tech`) |
| `TRANSCRIPT_DIR` | Path to agent sessions directory |
| `SESSIONS_JSON` | Path to sessions.json |
### Optional
| Variable | Default | Description |
| ---------------------------- | ------------------------- | ------------------------------------------ |
| `THROTTLE_MS` | `500` | Min interval between Mattermost updates |
| `IDLE_TIMEOUT_S` | `60` | Inactivity before marking session complete |
| `MAX_SESSION_DURATION_S` | `1800` | Hard timeout per session (30 min) |
| `MAX_STATUS_LINES` | `15` | Max lines in status box (oldest dropped) |
| `MAX_ACTIVE_SESSIONS` | `20` | Concurrent status box limit |
| `MAX_MESSAGE_CHARS` | `15000` | Mattermost post truncation limit |
| `HEALTH_PORT` | `9090` | Health endpoint port (0 = disabled) |
| `LOG_LEVEL` | `info` | Logging level (pino) |
| `PID_FILE` | `/tmp/status-watcher.pid` | PID file location |
| `CIRCUIT_BREAKER_THRESHOLD` | `5` | Failures before circuit opens |
| `CIRCUIT_BREAKER_COOLDOWN_S` | `30` | Cooldown before half-open probe |
| `TOOL_LABELS_FILE` | _(built-in)_ | External tool labels JSON override |
| `DEFAULT_CHANNEL` | _none_ | Fallback channel for non-MM sessions |
| `PLUGIN_URL` | _none_ | Plugin endpoint URL (enables plugin mode) |
| `PLUGIN_SECRET` | _none_ | Shared secret for plugin authentication |
| `PLUGIN_ENABLED` | `true` | Enable/disable plugin auto-detection |
## Status Box Format
```
[ACTIVE] main | 38s
Reading live-status source code...
exec: ls /agents/sessions [OK]
Analyzing agent configurations...
exec: grep -r live-status [OK]
Writing new implementation...
Sub-agent: proj035-planner
Reading protocol...
Analyzing JSONL format...
[DONE] 28s
Plan ready. Awaiting approval.
[DONE] 53s | 12.4k tokens
```
## Daemon Management
```sh
# Start
node src/watcher-manager.js start
# Stop (graceful shutdown)
node src/watcher-manager.js stop
# Status
node src/watcher-manager.js status
# Pass-through via legacy CLI
live-status start-watcher
live-status stop-watcher
# Health check
curl http://localhost:9090/health
```
## Deployment Options
### Hook (default)
The `gateway:startup` hook in `hooks/status-watcher-hook/` auto-starts the daemon.
No configuration needed beyond deploying the hook.
### systemd
```sh
# Copy service file
cp deploy/status-watcher.service /etc/systemd/system/
# Create env file
cat > /etc/status-watcher.env <<EOF
MM_TOKEN=your_token
MM_URL=https://slack.solio.tech
TRANSCRIPT_DIR=/home/node/.openclaw/agents/main/sessions
SESSIONS_JSON=/home/node/.openclaw/agents/main/sessions/sessions.json
EOF
systemctl enable --now status-watcher
```
### Docker
```sh
docker build -f deploy/Dockerfile -t status-watcher .
docker run -d \
-e MM_TOKEN=your_token \
-e MM_URL=https://slack.solio.tech \
-e TRANSCRIPT_DIR=/sessions \
-e SESSIONS_JSON=/sessions/sessions.json \
-v /home/node/.openclaw/agents:/sessions:ro \
-p 9090:9090 \
status-watcher
```
## Changelog
### v4.1.0 (2026-03-09)
**New: Floating Widget** (`plugin/webapp/src/components/floating_widget.tsx`)
- Registers as a root component via `registerRootComponent` — always visible, not tied to any post position
- Auto-shows when an agent session becomes active (WebSocket event), auto-hides 5 seconds after completion
- Draggable — drag to any screen position, position persisted in `localStorage`
- Collapsible to a pulsing dot with session count badge
- Shows agent name, elapsed time, and last 5 status lines of the most active session
- Click to expand or jump to the thread
**Fix: RHS Panel blank after page refresh** (`plugin/webapp/src/components/rhs_panel.tsx`)
- Previously the panel was empty after a browser refresh because it only showed sessions received via WebSocket during the current page load
- Now fetches `GET /api/v1/sessions` on mount to pre-populate with existing active sessions
- WebSocket updates continue to keep it live after initial hydration
**Fix: Plugin auth for browser requests** (`plugin/server/api.go`)
- Previously all plugin API requests required the shared secret (daemon token)
- Browser-side `fetch()` calls from the webapp can't include the shared secret
- Added dual auth: `GET` endpoints now also accept Mattermost session auth (`Mattermost-User-Id` header, auto-injected by the Mattermost server)
- Write operations (`POST`/`PUT`/`DELETE`) still require the shared secret — daemon-only
**New: Session cleanup goroutine** (`plugin/server/plugin.go`, `plugin/server/store.go`)
- Added `LastUpdateMs` timestamp field to `SessionData`
- Cleanup goroutine runs every 5 minutes in `OnActivate`
- Sessions active >30 minutes with no update are marked `interrupted` (daemon likely crashed)
- Non-active sessions older than 1 hour are deleted from the KV store
- Prevents indefinite accumulation of orphaned sessions from daemon crashes
**Fix: KV store scan optimization** (`plugin/server/store.go`)
- `ListActiveSessions` now filters by `ls_session_` key prefix before deserializing
- Avoids scanning unrelated KV entries from other plugins
---
## Upgrade from v1
v1 required agents to call `live-status create/update/complete` manually.
AGENTS.md contained a large "Live Status Protocol (MANDATORY)" section.
### What changes
1. The daemon handles all updates — no manual calls needed.
2. AGENTS.md protocol section can be removed (see `docs/v1-removal-checklist.md`).
3. `skill/SKILL.md` is now 9 lines: "status is automatic".
4. `live-status` CLI still works for manual use but prints a deprecation notice.
### Migration steps
1. Run `bash install.sh` to deploy v4.
2. Restart the gateway (hook activates).
3. Verify the daemon is running: `curl localhost:9090/health`
4. After 1+ hours of verified operation, remove the v1 AGENTS.md sections
(see `docs/v1-removal-checklist.md` for exact sections to remove).
## Mattermost Plugin
The plugin (`plugin/`) provides WebSocket-based live rendering — no "(edited)" labels, full terminal UI with theme support.
### Requirements
- Mattermost 7.0+
- Go 1.21+ (for building server binary)
- Node.js 18+ (for building React webapp)
- A bot token with System Admin or plugin management rights (for deployment)
### Build and Deploy
```sh
# Build server + webapp
cd plugin
make all
# Deploy to Mattermost (uploads + enables plugin)
MM_URL=https://your-mattermost.example.com \
MM_TOKEN=your_system_admin_token \
make deploy
# Verify plugin is healthy
PLUGIN_SECRET=your_shared_secret \
MM_URL=https://your-mattermost.example.com \
make health
```
### Plugin Configuration (Admin Console)
After deploying, configure in **System Console > Plugins > OpenClaw Live Status**:
| Setting | Description | Default |
|---------|-------------|---------|
| `SharedSecret` | Shared secret between plugin and daemon. Must match `PLUGIN_SECRET` in `.env.daemon`. | _(empty — set this)_ |
| `MaxActiveSessions` | Max simultaneous tracked sessions | 20 |
| `MaxStatusLines` | Max status lines per session | 30 |
### Manual Deploy (when plugin uploads are disabled)
If your Mattermost server has plugin uploads disabled (common in self-hosted setups), deploy directly to the host filesystem:
```sh
# Build the package
cd plugin && make package
# Outputs: plugin/dist/com.openclaw.livestatus.tar.gz
# Extract to Mattermost plugins volume (adjust path to match your setup)
tar xzf plugin/dist/com.openclaw.livestatus.tar.gz \
-C /opt/mattermost/volumes/app/mattermost/plugins/
# Restart or reload plugin via API
curl -X POST \
-H "Authorization: Bearer $MM_TOKEN" \
"$MM_URL/api/v4/plugins/com.openclaw.livestatus/enable"
```
### Plugin Auth Model
The plugin uses dual authentication:
- **Shared secret** (Bearer token in `Authorization` header) — used by the daemon for all write operations (POST/PUT/DELETE sessions)
- **Mattermost session** (`Mattermost-User-Id` header, auto-injected by the Mattermost server) — used by the browser webapp for read-only operations (GET sessions, GET health)
This means the RHS panel and floating widget can fetch existing sessions on page load without needing the shared secret in the frontend.
---
## Troubleshooting
**Daemon not starting:**
- Check PID file: `cat /tmp/status-watcher.pid`
- Check env vars: `MM_TOKEN`, `MM_URL`, `TRANSCRIPT_DIR`, `SESSIONS_JSON` must all be set
- Start manually and check logs: `node src/watcher-manager.js start`
**No status updates appearing:**
- Check health endpoint: `curl localhost:9090/health`
- Check circuit breaker state (shown in health response)
- Verify `MM_TOKEN` has permission to post in the target channel
**Duplicate status boxes:**
- Multiple daemon instances — check PID file, kill extras
- `node src/watcher-manager.js status` shows if it's running
**Session compaction:**
- When JSONL is truncated, the watcher detects it (stat.size < lastOffset)
- Offset resets, status box shows `[session compacted - continuing]`
- No crash, no data loss
## Development
```sh
# Run all tests
npm test
# Run unit tests only
npm run test-unit
# Run integration tests only
npm run test-integration
# Lint + format + test
make check
```
## Project Structure
```
src/
watcher-manager.js Entrypoint; PID file; graceful shutdown
status-watcher.js JSONL file watcher (inotify)
session-monitor.js sessions.json poller (2s interval)
status-box.js Mattermost post manager (throttle, circuit breaker)
status-formatter.js Status box text renderer
circuit-breaker.js Circuit breaker state machine
config.js Env var config with validation
logger.js pino wrapper
health.js HTTP health endpoint
tool-labels.js Tool name -> label resolver
tool-labels.json Built-in tool label defaults
live-status.js Legacy CLI (deprecated; backward compat)
hooks/
status-watcher-hook/ gateway:startup hook (auto-start daemon)
deploy/
status-watcher.service systemd unit file
Dockerfile Container deployment
test/
unit/ Unit tests (59 tests)
integration/ Integration tests (36 tests)
```

24
STATE.json Normal file
View File

@@ -0,0 +1,24 @@
{
"projectId": "PROJ-035",
"version": "v4.1",
"state": "IMPLEMENTATION_COMPLETE",
"planVersion": "v5-beta",
"phase": 6,
"totalPhases": 6,
"lastAgent": "main",
"lastUpdated": "2026-03-07T22:15:00Z",
"planPostedTo": "gitea",
"giteaRepo": "ROOH/MATTERMOST_OPENCLAW_LIVESTATUS",
"giteaIssue": 4,
"components": {
"daemon": "src/ (Node.js, 96 tests)",
"plugin_server": "plugin/server/ (Go, Mattermost plugin SDK)",
"plugin_webapp": "plugin/webapp/ (React/TypeScript, 12.9KB bundle)",
"hook": "hooks/status-watcher-hook/"
},
"deployment": {
"plugin": "com.openclaw.livestatus (active on Mattermost v11.4.0)",
"daemon": "watcher-manager.js (PID file at /tmp/status-watcher.pid)",
"renderingMode": "plugin (WebSocket) with REST API fallback"
}
}

53
deploy-to-agents.sh Executable file
View File

@@ -0,0 +1,53 @@
#!/bin/bash
# deploy-to-agents.sh — Deploy Live Status v4 to OpenClaw workspace
#
# Deploys the gateway:startup hook and installs npm dependencies.
# Does NOT inject anything into AGENTS.md.
# The watcher daemon auto-starts on next gateway restart.
#
# Usage: bash deploy-to-agents.sh [--workspace DIR]
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORKSPACE="${WORKSPACE:-/home/node/.openclaw/workspace}"
# Parse flags
while [[ "$#" -gt 0 ]]; do
case "$1" in
--workspace) WORKSPACE="$2"; shift 2 ;;
*) shift ;;
esac
done
echo "==================================="
echo " Live Status v4 Deploy"
echo "==================================="
echo "Workspace: $WORKSPACE"
echo ""
# 1. npm install
echo "[1/2] Installing npm dependencies..."
cd "$SCRIPT_DIR"
npm install --production
echo " Done."
# 2. Deploy hook
echo "[2/2] Deploying gateway:startup hook..."
HOOKS_DIR="$WORKSPACE/hooks"
mkdir -p "$HOOKS_DIR/status-watcher-hook"
cp -r "$SCRIPT_DIR/hooks/status-watcher-hook/." "$HOOKS_DIR/status-watcher-hook/"
echo " Hook deployed to: $HOOKS_DIR/status-watcher-hook/"
echo ""
echo "==================================="
echo " Deployment Complete"
echo "==================================="
echo ""
echo "The watcher will auto-start on next gateway startup."
echo ""
echo "To activate immediately, ensure these env vars are set, then run:"
echo " node $SCRIPT_DIR/src/watcher-manager.js start"
echo ""
echo "Required env vars: MM_TOKEN, MM_URL, TRANSCRIPT_DIR, SESSIONS_JSON"
echo "See install.sh for full config reference."

36
deploy/Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
FROM node:22-alpine
LABEL description="Live Status v4 - OpenClaw session watcher daemon"
LABEL source="https://git.eeqj.de/ROOH/MATTERMOST_OPENCLAW_LIVESTATUS"
WORKDIR /app
# Copy package files and install production dependencies only
COPY package.json package-lock.json ./
RUN npm ci --production
# Copy source and supporting files
COPY src/ ./src/
COPY skill/ ./skill/
# Environment variables (required — set at runtime)
# MM_TOKEN, MM_URL, TRANSCRIPT_DIR, SESSIONS_JSON must be provided
ENV NODE_ENV=production \
LOG_LEVEL=info \
HEALTH_PORT=9090 \
THROTTLE_MS=500 \
IDLE_TIMEOUT_S=60 \
MAX_STATUS_LINES=15 \
MAX_ACTIVE_SESSIONS=20 \
PID_FILE=/tmp/status-watcher.pid
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:${HEALTH_PORT}/health || exit 1
# Run as non-root
USER node
EXPOSE ${HEALTH_PORT}
CMD ["node", "src/watcher-manager.js", "start"]

View File

@@ -0,0 +1,36 @@
[Unit]
Description=Live Status v4 - OpenClaw session watcher daemon
Documentation=https://git.eeqj.de/ROOH/MATTERMOST_OPENCLAW_LIVESTATUS
After=network.target
[Service]
Type=simple
User=node
WorkingDirectory=/opt/openclaw-live-status
# Load environment variables from file
EnvironmentFile=/etc/status-watcher.env
# Start the watcher daemon directly (not via CLI wrapper)
ExecStart=/usr/bin/node /opt/openclaw-live-status/src/watcher-manager.js start
# Graceful shutdown — watcher handles SIGTERM (marks boxes interrupted, flushes)
ExecStop=/bin/kill -TERM $MAINPID
# Restart policy
Restart=on-failure
RestartSec=5s
StartLimitBurst=3
StartLimitIntervalSec=60s
# Logging — output goes to journald
StandardOutput=journal
StandardError=journal
SyslogIdentifier=status-watcher
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target

107
discoveries/README.md Normal file
View File

@@ -0,0 +1,107 @@
# Discovery Findings: Live Status v4
## Overview
Planner sub-agent (proj035-planner) conducted inline discovery before drafting the plan. Key findings are documented here.
## Discovery 1: JSONL Transcript Format
**Confirmed format (JSONL, version 3):**
Each line is a JSON object with `type` field:
- `session` — First line only. Contains `id` (UUID), `version: 3`, `cwd`
- `model_change``provider`, `modelId` change events
- `thinking_level_change` — thinking on/off
- `custom` — Subtypes: `model-snapshot`, `openclaw.cache-ttl` (turn boundary marker)
- `message` — Main event type. `role` = `user`, `assistant`, or `toolResult`
Message content types:
- `{type: "text", text: "..."}` — plain text from any role
- `{type: "toolCall", id, name, arguments: {...}}` — tool invocations in assistant messages
- `{type: "thinking", thinking: "..."}` — internal reasoning (thinking mode)
Assistant messages carry extra fields: `api`, `provider`, `model`, `usage`, `stopReason`, `timestamp`
ToolResult messages carry: `toolCallId`, `toolName`, `isError`, `content: [{type, text}]`
**Key signals for watcher:**
- `stopReason: "stop"` + no new lines → agent turn complete → idle
- `stopReason: "toolUse"` → agent waiting for tool results → NOT idle
- `custom.customType: "openclaw.cache-ttl"` → turn boundary marker
## Discovery 2: Session Keying
Session keys in sessions.json follow the pattern: `agent:{agentId}:{context}`
Examples:
- `agent:main:main` — direct session
- `agent:main:mattermost:channel:{channelId}` — channel session
- `agent:main:mattermost:channel:{channelId}:thread:{threadId}` — thread session
- `agent:main:subagent:{uuid}` — SUB-AGENT SESSION (has `spawnedBy`, `spawnDepth`, `label`)
- `agent:main:hook:gitea:{repo}:issue:{n}` — hook-triggered session
- `agent:main:cron:{name}` — cron session
Sub-agent entry fields relevant to watcher:
- `sessionId` — maps to `{sessionId}.jsonl` filename
- `spawnedBy` — parent session key (for nesting)
- `spawnDepth` — nesting depth (1 = direct child of main)
- `label` — human-readable name (e.g., "proj035-planner")
- `channel` — delivery channel (mattermost, etc.)
Sessions files: `/home/node/.openclaw/agents/{agentId}/sessions/`
- `sessions.json` — registry (updated on every message)
- `{uuid}.jsonl` — transcript files
- `{uuid}-topic-{topicId}.jsonl` — topic-scoped transcripts
## Discovery 3: OpenClaw Hook Events
Available internal hook events (confirmed from source):
- `command:new`, `command:reset`, `command:stop` — user commands
- `command` — all commands
- `agent:bootstrap` — before workspace files injected
- `gateway:startup` — gateway startup (250ms after channels start)
**NO session:start or session:end hooks exist.** The hooks system covers commands and gateway lifecycle only, NOT individual agent runs.
Sub-agent lifecycle hooks (`subagent_spawned`, `subagent_ended`) are channel plugin hooks, not internal hooks — not directly usable from workspace hooks.
**Hook handler files:** workspace hooks support `handler.ts` OR `handler.js` (both discovered automatically via `handlerCandidates` in workspace.ts).
## Discovery 4: Mattermost API
- `PostEditTimeLimit = -1` — unlimited edits on this server
- Bot token: `<redacted>` (default/main bot account, set via MM_BOT_TOKEN env var)
- Multiple bot accounts available per agent (see openclaw.json `accounts`)
- API base: `https://slack.solio.tech/api/v4`
- Post update: `PUT /api/v4/posts/{id}` — no time limit, no count limit
## Discovery 5: Current v1 Failure Modes
- Agents call `live-status create/update/complete` manually
- `deploy-to-agents.sh` injects verbose 200-word protocol into AGENTS.md
- Agents forget to call it (no enforcement mechanism)
- IDs get lost between tool calls (no persistent state)
- No sub-agent visibility (sub-agents have separate sessions)
- Thread sessions create separate OpenClaw sessions → IDs not shared
- Final response dumps multiple status updates (spam from forgotten updates)
## Discovery 6: Repo State
- Workspace copy: `/home/node/.openclaw/workspace/projects/openclaw-live-status/`
- `src/live-status.js` — 283 lines, v2 CLI with --agent, --channel, --reply-to, create/update/complete/delete
- `deploy-to-agents.sh` — AGENTS.md injection approach
- `skill/SKILL.md` — manual usage instructions
- `src/agent-accounts.json` — agent→bot account mapping
- Remote repo (ROOH/MATTERMOST_OPENCLAW_LIVESTATUS): `src/live-status.js` is outdated (114 lines v1)
- Makefile with check/test/lint/fmt targets already exists in remote repo
## Synthesis
The transcript-tailing daemon approach is sound and the format is stable. The key implementation insight is: **watch sessions.json to discover new sessions, then watch each JSONL file for that session**. Sub-agents are automatically discoverable via `spawnedBy` fields. The hook system can auto-start the daemon on gateway startup via `gateway:startup` event. No new OpenClaw core changes are needed.

View File

@@ -0,0 +1,129 @@
# v1 Live Status Removal Checklist
SAFETY: Do NOT execute these removals until the v4 watcher has been running
successfully in production for at least 1 hour (confirmed via health endpoint
and at least one real status box auto-update observed).
Verify readiness:
node src/watcher-manager.js status
curl http://localhost:9090/health
---
## File: /home/node/.openclaw/workspace/AGENTS.md
### Section to remove (lines 645-669 as of 2026-03-07):
Remove the entire section:
```
## Live Status Protocol (MANDATORY - NO EXCEPTIONS)
**For ANY multi-step task, you MUST use live-status. This is not optional.**
### How:
1. **FIRST action of any task:** Create the status box:
```
live-status --channel <CHANNEL> --reply-to <THREAD> --agent main create "[Task Name] Starting..."
```
2. **Before EVERY tool call:** Update the status box with what you're about to do.
3. **After EVERY tool result:** Update with the outcome.
4. **When done:** Mark complete.
### Rules:
- **Always pass `--agent main`** (e.g., `--agent main`). Without this, posts go to the wrong bot.
- **Never skip updates.** If the human can't see what you're doing in real-time, you're doing it wrong.
- **The status box is your primary output during work.** Chat messages are for final results only.
### Agent IDs for reference:
- god-agent, xen, main, coder-agent, global-calendar, gym-designer, nutrition-agent
**Why:** The human needs to see progress in real-time, not just the final answer.
```
### Inline references to update (do not remove — update to reflect v4):
Line ~224: "Create a live-status box and pass the post ID to the sub-agent"
-> Update to: "The watcher auto-tracks sub-agent sessions (no manual action needed)"
Line ~300: "Does this task need live-status? Always for: research, installation..."
-> Remove this decision-tree item; status is automatic in v4
Line ~302: "Verify delivery. After every send, confirm: ... live-status created."
-> Remove "live-status created" from the verification checklist
---
## File: /home/node/.openclaw/agents/xen/workspace/AGENTS.md
### Section to remove (around line 214):
Same "Live Status Protocol (MANDATORY - NO EXCEPTIONS)" section as above.
Full section from "## Live Status Protocol" heading to end of "Why:" paragraph.
---
## File: /home/node/.openclaw/agents/coder-agent/workspace/AGENTS.md
### Section to remove (around line 214):
Same "Live Status Protocol (MANDATORY - NO EXCEPTIONS)" section.
---
## File: /home/node/.openclaw/workspaces/workspace-gym/AGENTS.md
### Section to remove (around line 214):
Same "Live Status Protocol (MANDATORY - NO EXCEPTIONS)" section.
---
## File: /home/node/.openclaw/workspaces/workspace-global-calendar/AGENTS.md
### Section to remove (around line 214):
Same "Live Status Protocol (MANDATORY - NO EXCEPTIONS)" section.
---
## File: /home/node/.openclaw/workspaces/workspace-god-agent/AGENTS.md
### Section to remove (around line 218):
Same "Live Status Protocol (MANDATORY - NO EXCEPTIONS)" section.
---
## Commit message (when ready to execute)
feat: remove v1 live-status injection (v4 watcher active)
The v4 status-watcher daemon has been running for X hours in production.
Status updates are now automatic. Removing the v1 manual protocol from
all agent AGENTS.md files.
Files changed:
- /home/node/.openclaw/workspace/AGENTS.md
- /home/node/.openclaw/agents/xen/workspace/AGENTS.md
- /home/node/.openclaw/agents/coder-agent/workspace/AGENTS.md
- /home/node/.openclaw/workspaces/workspace-gym/AGENTS.md
- /home/node/.openclaw/workspaces/workspace-global-calendar/AGENTS.md
- /home/node/.openclaw/workspaces/workspace-god-agent/AGENTS.md
---
## Notes
- The workspace AGENTS.md also has inline references (lines ~300, ~302) that
reference live-status in the Reply Protocol section. These should be updated,
not deleted, since the Reply Protocol is still valid.
- The "Make It Yours" section at line 640 is unrelated and should be kept.
- Backup files (/home/node/.openclaw/backups/) do not need updating.
- The skill/SKILL.md is already updated to v4 (9 lines, "status is automatic").

View File

@@ -0,0 +1,52 @@
---
name: status-watcher-hook
description: 'Auto-starts the Live Status v4 watcher daemon on gateway startup'
metadata: { 'openclaw': { 'emoji': '📡', 'events': ['gateway:startup'] } }
---
# status-watcher-hook
Auto-starts the Live Status v4 daemon when the OpenClaw gateway starts.
## Description
On gateway startup, this hook checks whether the status-watcher daemon is already
running (via PID file). If not, it spawns `watcher-manager.js start` as a detached
background process, then exits immediately. The daemon continues running independently
of this hook handler.
## Required Environment Variables
The following environment variables must be set for the watcher to function:
```
MM_BOT_TOKEN Mattermost bot token
MM_BASE_URL Mattermost base URL (e.g. https://slack.solio.tech)
```
Optional (defaults shown):
```
TRANSCRIPT_DIR /home/node/.openclaw/agents
THROTTLE_MS 500
IDLE_TIMEOUT_S 60
MAX_STATUS_LINES 20
MAX_ACTIVE_SESSIONS 20
MAX_MESSAGE_CHARS 15000
HEALTH_PORT 9090
LOG_LEVEL info
PID_FILE /tmp/status-watcher.pid
CIRCUIT_BREAKER_THRESHOLD 5
CIRCUIT_BREAKER_COOLDOWN_S 30
```
## Installation
This hook is deployed automatically by `install.sh` or `deploy-to-agents.sh`.
To deploy manually:
```sh
cp -r hooks/status-watcher-hook /home/node/.openclaw/workspace/hooks/
```
The hook activates on the next gateway startup.

View File

@@ -0,0 +1,156 @@
'use strict';
/**
* status-watcher-hook/handler.js
*
* Spawns the Live Status v4 watcher-manager daemon on gateway startup.
*
* Events: ["gateway:startup"]
*
* Behavior:
* 1. Check PID file — if watcher is already running, do nothing.
* 2. If not running, spawn watcher-manager.js as a detached background process.
* 3. The hook handler returns immediately; the daemon runs independently.
*/
/* eslint-disable no-console */
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
// PID file location (must match watcher-manager.js default)
const PID_FILE = process.env.PID_FILE || '/tmp/status-watcher.pid';
// Log file location
const LOG_FILE = process.env.LIVESTATUS_LOG_FILE || '/tmp/status-watcher.log';
// Path to watcher-manager.js (relative to this hook file's location)
// Hook is in: workspace/hooks/status-watcher-hook/handler.js
// Watcher is in: workspace/projects/openclaw-live-status/src/watcher-manager.js
const WATCHER_PATH = path.resolve(
__dirname,
'../../projects/openclaw-live-status/src/watcher-manager.js',
);
// Path to .env.daemon config file
const ENV_DAEMON_PATH = path.resolve(
__dirname,
'../../projects/openclaw-live-status/.env.daemon',
);
/**
* Check if a process is alive given its PID.
* Returns true if process exists and is running.
*/
function isProcessRunning(pid) {
try {
process.kill(pid, 0);
return true;
} catch (_err) {
return false;
}
}
/**
* Check if the watcher daemon is already running via PID file.
* Returns true if running, false if not (or PID file stale/missing).
*/
function isWatcherRunning() {
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
const pidStr = fs.readFileSync(PID_FILE, 'utf8').trim();
const pid = parseInt(pidStr, 10);
if (isNaN(pid) || pid <= 0) return false;
return isProcessRunning(pid);
} catch (_err) {
// PID file missing or unreadable — watcher is not running
return false;
}
}
/**
* Load .env.daemon file into an env object (key=value lines, ignoring comments).
* Returns merged env: process.env + .env.daemon overrides.
*/
function loadDaemonEnv() {
const env = Object.assign({}, process.env);
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
const content = fs.readFileSync(ENV_DAEMON_PATH, 'utf8');
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIdx = trimmed.indexOf('=');
if (eqIdx <= 0) continue;
const key = trimmed.slice(0, eqIdx).trim();
const val = trimmed.slice(eqIdx + 1).trim();
env[key] = val; // eslint-disable-line security/detect-object-injection
}
console.log('[status-watcher-hook] Loaded daemon config from', ENV_DAEMON_PATH);
} catch (_err) {
console.warn('[status-watcher-hook] No .env.daemon found at', ENV_DAEMON_PATH, '— using process.env only');
}
return env;
}
/**
* Spawn the watcher daemon as a detached background process.
* The parent (this hook handler) does not wait for it.
*/
function spawnWatcher() {
if (!fs.existsSync(WATCHER_PATH)) {
console.error('[status-watcher-hook] watcher-manager.js not found at:', WATCHER_PATH);
console.error('[status-watcher-hook] Deploy the live-status project first: see install.sh');
return;
}
console.log('[status-watcher-hook] Starting Live Status v4 watcher daemon...');
// Load .env.daemon for proper config (plugin URL/secret, bot user ID, etc.)
const daemonEnv = loadDaemonEnv();
// Open log file for append — never /dev/null
let logFd;
try {
logFd = fs.openSync(LOG_FILE, 'a');
console.log('[status-watcher-hook] Logging to', LOG_FILE);
} catch (err) {
console.error('[status-watcher-hook] Cannot open log file', LOG_FILE, ':', err.message);
logFd = 'ignore';
}
const child = spawn(process.execPath, [WATCHER_PATH, 'start'], {
detached: true,
stdio: ['ignore', logFd, logFd],
env: daemonEnv,
});
child.unref();
// Close the fd in the parent process (child inherits its own copy)
if (typeof logFd === 'number') {
try { fs.closeSync(logFd); } catch (_e) { /* ignore */ }
}
console.log(
'[status-watcher-hook] Watcher daemon spawned (PID will be written to',
PID_FILE + ')',
);
}
/**
* Hook entry point — called by OpenClaw on gateway:startup event.
*/
async function handle(_event) {
if (isWatcherRunning()) {
console.log('[status-watcher-hook] Watcher already running, skipping spawn.');
return;
}
spawnWatcher();
}
// OpenClaw hook loader expects a default export
module.exports = handle;
module.exports.default = handle;

View File

@@ -1,69 +1,81 @@
#!/bin/bash
# install.sh — Live Status v4 installer
#
# Installs npm dependencies and deploys the gateway:startup hook.
# The watcher daemon starts automatically on next gateway restart.
#
# Usage: bash install.sh [--workspace DIR]
# OpenClaw Live Status Installer (Wizard Mode)
set -e
echo "=========================================="
echo " OpenClaw Live Status Tool - Installer "
echo "=========================================="
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORKSPACE="${1:-/home/node/.openclaw/workspace}"
# 1. Install Binary
echo "[+] Installing binary to /usr/local/bin/live-status..."
cp src/live-status.js /usr/local/bin/live-status
chmod +x /usr/local/bin/live-status
# Parse flags
while [[ "$#" -gt 0 ]]; do
case "$1" in
--workspace) WORKSPACE="$2"; shift 2 ;;
*) shift ;;
esac
done
# 2. Install Skill
echo "[+] Installing skill to /app/skills/live-status..."
mkdir -p /app/skills/live-status
cp skill/SKILL.md /app/skills/live-status/SKILL.md
# 3. Global Agent Configuration (Injection)
echo "[+] Configuring global agent defaults..."
AGENTS_FILE="/home/node/.openclaw/workspace/AGENTS.md"
if [ -f "$AGENTS_FILE" ]; then
if grep -q "Live Status Protocol" "$AGENTS_FILE"; then
echo " > AGENTS.md already configured."
else
echo " > Injecting mandatory protocol into AGENTS.md..."
cat >> "$AGENTS_FILE" <<EOF
## 📡 Live Status Protocol (MANDATORY)
**For ANY multi-step task (e.g., research, downloading, installing, compiling):**
1. **Read Skill:** Load \`/app/skills/live-status/SKILL.md\`.
2. **Initialize:** Run \`live-status create\` with a code block (\`[STATUS] INITIALIZING...\`).
3. **Capture ID:** Store the Post ID returned by \`create\`.
4. **Update:** Frequently run \`live-status update <ID>\` to stream progress.
5. **Complete:** Mark as \`[COMPLETED]\` when done.
**Why:** This provides "Antigravity-style" visibility without spamming channels. Sub-agents MUST use this tool instead of \`message\` for progress reports.
EOF
fi
else
echo " > Warning: AGENTS.md not found. Skipping injection."
fi
# 4. Mattermost Configuration (Wizard)
echo "==================================="
echo " Live Status v4 Installer"
echo "==================================="
echo "Project: $SCRIPT_DIR"
echo "Workspace: $WORKSPACE"
echo ""
echo "[?] Mattermost Configuration Check:"
# Check if the installed tool still has the default placeholder
if grep -q "DEFAULT_TOKEN_PLACEHOLDER" /usr/local/bin/live-status; then
echo " > Default token detected."
read -p " > Enter your Mattermost Bot Token: " NEW_TOKEN
if [[ -n "$NEW_TOKEN" ]]; then
# Replace the placeholder in the INSTALLED binary (not the source)
sed -i "s/DEFAULT_TOKEN_PLACEHOLDER/$NEW_TOKEN/g" /usr/local/bin/live-status
echo " > Token configured successfully."
else
echo " > No token entered. Tool may not function."
fi
else
echo " > Custom token already configured."
fi
# 1. Install npm dependencies
echo "[1/3] Installing npm dependencies..."
cd "$SCRIPT_DIR"
npm install --production
echo " Done."
# 2. Deploy hook
echo "[2/3] Deploying gateway:startup hook..."
HOOKS_DIR="$WORKSPACE/hooks"
mkdir -p "$HOOKS_DIR/status-watcher-hook"
cp -r "$SCRIPT_DIR/hooks/status-watcher-hook/." "$HOOKS_DIR/status-watcher-hook/"
echo " Hook deployed to: $HOOKS_DIR/status-watcher-hook/"
# 3. Print required environment variables
echo "[3/3] Post-install configuration"
echo ""
echo "=========================================="
echo " Installation Complete!"
echo "=========================================="
echo "Usage: live-status create \"...\""
echo "==================================="
echo " Required Environment Variables"
echo "==================================="
echo ""
echo "Set these before the watcher will function:"
echo ""
echo " MM_TOKEN Mattermost bot token"
echo " (find in openclaw.json -> mattermost.accounts)"
echo ""
echo " MM_URL Mattermost base URL"
echo " e.g. https://slack.solio.tech"
echo ""
echo " TRANSCRIPT_DIR Path to agent sessions directory"
echo " e.g. /home/node/.openclaw/agents/main/sessions"
echo ""
echo " SESSIONS_JSON Path to sessions.json"
echo " e.g. /home/node/.openclaw/agents/main/sessions/sessions.json"
echo ""
echo "Optional (shown with defaults):"
echo " THROTTLE_MS=500 Update interval (ms)"
echo " IDLE_TIMEOUT_S=60 Idle before marking session done"
echo " MAX_STATUS_LINES=15 Lines shown in status box"
echo " MAX_ACTIVE_SESSIONS=20 Concurrent session limit"
echo " HEALTH_PORT=9090 Health endpoint port (0=disabled)"
echo " LOG_LEVEL=info Logging level"
echo " PID_FILE=/tmp/status-watcher.pid"
echo ""
echo "==================================="
echo " Installation Complete"
echo "==================================="
echo ""
echo "The watcher starts automatically on next gateway startup."
echo "To start immediately (with env vars set):"
echo " node $SCRIPT_DIR/src/watcher-manager.js start"
echo ""
echo "Health check (once running):"
echo " curl http://localhost:9090/health"

151
package-lock.json generated
View File

@@ -1,15 +1,18 @@
{
"name": "mattermost-openclaw-livestatus",
"version": "1.0.0",
"version": "4.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mattermost-openclaw-livestatus",
"version": "1.0.0",
"version": "4.0.0",
"dependencies": {
"pino": "^9.14.0"
},
"devDependencies": {
"eslint": "^8.56.0",
"eslint-plugin-security": "^2.1.0",
"eslint": "^8.57.1",
"eslint-plugin-security": "^2.1.1",
"prettier": "^3.2.0"
}
},
@@ -152,6 +155,12 @@
"node": ">= 8"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@@ -232,6 +241,15 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -608,9 +626,9 @@
}
},
"node_modules/flatted": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz",
"integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==",
"dev": true,
"license": "ISC"
},
@@ -893,6 +911,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -996,6 +1023,43 @@
"node": ">=8"
}
},
"node_modules/pino": {
"version": "9.14.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
"integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^2.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^3.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
"integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -1007,9 +1071,9 @@
}
},
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.0.tgz",
"integrity": "sha512-/vBUecTGaPlRVwyZVROVC58bYIScqaoEJzZmzQXXrZOzqn0TwWz0EnOozOlFO/YAImRnb7XsKpTCd3m1SjS2Ww==",
"dev": true,
"license": "MIT",
"bin": {
@@ -1022,6 +1086,22 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -1053,6 +1133,21 @@
],
"license": "MIT"
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/regexp-tree": {
"version": "0.1.27",
"resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz",
@@ -1135,6 +1230,15 @@
"regexp-tree": "~0.1.1"
}
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -1158,6 +1262,24 @@
"node": ">=8"
}
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@@ -1204,6 +1326,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/thread-stream": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
"integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==",
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@@ -1,11 +1,22 @@
{
"name": "mattermost-openclaw-livestatus",
"version": "1.0.0",
"version": "4.0.0",
"private": true,
"description": "OpenClaw Live Status Tool for Mattermost",
"main": "src/watcher-manager.js",
"scripts": {
"start": "node src/watcher-manager.js start",
"stop": "node src/watcher-manager.js stop",
"status": "node src/watcher-manager.js status",
"test": "node --test test/unit/*.test.js",
"test-integration": "node --test test/integration/*.test.js"
},
"dependencies": {
"pino": "^9.14.0"
},
"devDependencies": {
"eslint": "^8.56.0",
"eslint-plugin-security": "^2.1.0",
"eslint": "^8.57.1",
"eslint-plugin-security": "^2.1.1",
"prettier": "^3.2.0"
}
}

101
plugin/Makefile Normal file
View File

@@ -0,0 +1,101 @@
## Makefile — OpenClaw Live Status Mattermost Plugin
## Builds, packages, and deploys the plugin to a running Mattermost instance.
##
## Requirements:
## - Go 1.21+
## - Node.js 18+
## - curl + tar
##
## Usage:
## make all Build server + webapp
## make deploy Build and deploy to Mattermost (requires MM_URL + MM_TOKEN)
## make package Create distributable tar.gz
## make clean Remove build artifacts
PLUGIN_ID := com.openclaw.livestatus
PLUGIN_DIR := $(shell pwd)
SERVER_DIR := $(PLUGIN_DIR)/server
WEBAPP_DIR := $(PLUGIN_DIR)/webapp
# Build outputs
SERVER_BIN := $(SERVER_DIR)/dist/plugin-linux-amd64
WEBAPP_BUNDLE := $(WEBAPP_DIR)/dist/main.js
PACKAGE_FILE := $(PLUGIN_DIR)/dist/$(PLUGIN_ID).tar.gz
# Deployment (override via env or command line)
MM_URL ?= http://localhost:8065
MM_TOKEN ?=
.PHONY: all server webapp package deploy clean check-env
all: server webapp
server:
@echo ">> Building Go server (linux/amd64)..."
@mkdir -p $(SERVER_DIR)/dist
cd $(SERVER_DIR) && GOOS=linux GOARCH=amd64 go build -o dist/plugin-linux-amd64 .
@echo " Built: $(SERVER_BIN)"
webapp:
@echo ">> Building React webapp..."
cd $(WEBAPP_DIR) && npm install --legacy-peer-deps
cd $(WEBAPP_DIR) && npx webpack --mode production
@echo " Built: $(WEBAPP_BUNDLE)"
package: all
@echo ">> Packaging plugin..."
@mkdir -p $(PLUGIN_DIR)/dist
@rm -rf /tmp/$(PLUGIN_ID)
@mkdir -p /tmp/$(PLUGIN_ID)/server/dist /tmp/$(PLUGIN_ID)/webapp/dist /tmp/$(PLUGIN_ID)/assets
@cp $(PLUGIN_DIR)/plugin.json /tmp/$(PLUGIN_ID)/
@cp $(SERVER_BIN) /tmp/$(PLUGIN_ID)/server/dist/
@cp $(WEBAPP_BUNDLE) /tmp/$(PLUGIN_ID)/webapp/dist/
@[ -f $(PLUGIN_DIR)/assets/icon.svg ] && cp $(PLUGIN_DIR)/assets/icon.svg /tmp/$(PLUGIN_ID)/assets/ || true
@cd /tmp && tar czf $(PACKAGE_FILE) $(PLUGIN_ID)/
@rm -rf /tmp/$(PLUGIN_ID)
@echo " Package: $(PACKAGE_FILE)"
deploy: check-env package
@echo ">> Deploying plugin to $(MM_URL)..."
@# Disable existing plugin
@curl -sf -X POST \
-H "Authorization: Bearer $(MM_TOKEN)" \
"$(MM_URL)/api/v4/plugins/$(PLUGIN_ID)/disable" > /dev/null 2>&1 || true
@# Delete existing plugin
@curl -sf -X DELETE \
-H "Authorization: Bearer $(MM_TOKEN)" \
"$(MM_URL)/api/v4/plugins/$(PLUGIN_ID)" > /dev/null 2>&1 || true
@# Upload new plugin
@curl -sf -X POST \
-H "Authorization: Bearer $(MM_TOKEN)" \
-F "plugin=@$(PACKAGE_FILE)" \
-F "force=true" \
"$(MM_URL)/api/v4/plugins" | grep -q "id" && echo " Uploaded." || (echo " Upload failed (plugin uploads may be disabled)." && exit 1)
@# Enable plugin
@curl -sf -X POST \
-H "Authorization: Bearer $(MM_TOKEN)" \
"$(MM_URL)/api/v4/plugins/$(PLUGIN_ID)/enable" > /dev/null
@echo " Plugin enabled. Verifying health..."
@sleep 2
@echo " Done. Run 'make health' to verify."
health:
@PLUGIN_SECRET=$${PLUGIN_SECRET:-}; \
if [ -n "$$PLUGIN_SECRET" ]; then \
curl -sf -H "Authorization: Bearer $$PLUGIN_SECRET" \
"$(MM_URL)/plugins/$(PLUGIN_ID)/api/v1/health" | python3 -m json.tool 2>/dev/null || \
curl -sf -H "Authorization: Bearer $$PLUGIN_SECRET" \
"$(MM_URL)/plugins/$(PLUGIN_ID)/api/v1/health"; \
else \
echo "Set PLUGIN_SECRET to check health"; \
fi
check-env:
@if [ -z "$(MM_TOKEN)" ]; then \
echo "ERROR: MM_TOKEN is required. Set MM_TOKEN=your_bot_token"; \
exit 1; \
fi
clean:
@rm -rf $(SERVER_DIR)/dist $(WEBAPP_DIR)/dist $(PLUGIN_DIR)/dist
@echo ">> Cleaned build artifacts."

3
plugin/assets/icon.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#166de0">
<path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-1 14H5V6h14v12zM7 9h2v2H7zm0 4h2v2H7zm4-4h6v2h-6zm0 4h6v2h-6z"/>
</svg>

After

Width:  |  Height:  |  Size: 237 B

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
}
]
}
}

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

@@ -0,0 +1,401 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"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) {
path := r.URL.Path
// Auth middleware: two auth paths.
// 1. Shared secret (Bearer token) — used by the daemon for write operations.
// 2. Mattermost session (Mattermost-User-Id header) — used by browser requests.
// The Mattermost server automatically injects this header for authenticated
// requests routed through the plugin HTTP handler.
//
// Read-only endpoints (GET /sessions, GET /health) accept either auth method.
// Write endpoints (POST, PUT, DELETE) require the shared secret.
isReadOnly := r.Method == http.MethodGet && (path == "/api/v1/sessions" || path == "/api/v1/health")
config := p.getConfiguration()
hasSharedSecret := false
if config.SharedSecret != "" {
auth := r.Header.Get("Authorization")
expected := "Bearer " + config.SharedSecret
hasSharedSecret = (auth == expected)
}
// Check Mattermost session auth (browser requests).
// The MM server injects Mattermost-User-Id for authenticated users.
mmUserID := r.Header.Get("Mattermost-User-Id")
hasMattermostSession := mmUserID != ""
if isReadOnly {
// Read-only: accept either shared secret OR Mattermost session
if !hasSharedSecret && !hasMattermostSession {
http.Error(w, `{"error": "unauthorized: valid Mattermost session or shared secret required"}`, http.StatusUnauthorized)
return
}
} else {
// Write operations: require shared secret (daemon auth)
if !hasSharedSecret {
http.Error(w, `{"error": "unauthorized"}`, http.StatusUnauthorized)
return
}
}
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.ListAllSessions()
count := 0
if err == nil {
for _, s := range sessions {
if s.Status == "active" {
count++
}
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "healthy",
"active_sessions": count,
"plugin_id": "com.openclaw.livestatus",
})
}
// handleListSessions returns all sessions (active and non-active).
func (p *Plugin) handleListSessions(w http.ResponseWriter, r *http.Request) {
sessions, err := p.store.ListAllSessions()
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"`
BotUserID string `json:"bot_user_id,omitempty"`
}
// 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
}
// Validate KV key length — Mattermost enforces a 50-char limit.
// Encoded key = kvPrefix (11 chars) + url.PathEscape(sessionKey).
// Exceeding the limit causes KVSet to silently succeed but never store data.
if len(encodeKey(req.SessionKey)) > 50 {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": fmt.Sprintf("session_key too long: encoded key length %d exceeds 50-char KV limit", len(encodeKey(req.SessionKey))),
})
return
}
// Check max active sessions
config := p.getConfiguration()
allSessions, _ := p.store.ListAllSessions()
activeCount := 0
for _, s := range allSessions {
if s.Status == "active" {
activeCount++
}
}
if activeCount >= config.MaxActiveSessions {
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "max active sessions reached"})
return
}
// Create the custom post — UserId is required
// Use the bot user ID passed in the request, or fall back to plugin bot
userID := req.BotUserID
if userID == "" {
// Try to get plugin's own bot
userID = p.getBotUserID()
}
if userID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bot_user_id required (no plugin bot available)"})
return
}
post := &model.Post{
UserId: userID,
ChannelId: req.ChannelID,
RootId: req.RootID,
Type: "custom_livestatus",
Message: "Agent session active",
}
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
}
if createdPost == nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "CreatePost returned nil without error"})
return
}
// Store session data
now := time.Now().UnixMilli()
sessionData := SessionData{
SessionKey: req.SessionKey,
PostID: createdPost.Id,
ChannelID: req.ChannelID,
RootID: req.RootID,
AgentID: req.AgentID,
Status: "active",
Lines: []string{},
StartTimeMs: now,
LastUpdateMs: now,
}
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
existing.LastUpdateMs = time.Now().UnixMilli()
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 (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"})
}
// 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"})
}
// 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 {
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
}

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{})
}

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

@@ -0,0 +1,115 @@
package main
import (
"sync"
"time"
"github.com/mattermost/mattermost/server/public/model"
"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
// botUserIDLock synchronizes access to botUserID.
botUserIDLock sync.RWMutex
// botUserID is the plugin's bot user ID (created on activation).
botUserID string
// stopCleanup signals the cleanup goroutine to stop.
stopCleanup chan struct{}
}
// OnActivate is called when the plugin is activated.
func (p *Plugin) OnActivate() error {
p.store = NewStore(p.API)
// Ensure plugin bot user exists
botID, appErr := p.API.EnsureBotUser(&model.Bot{
Username: "livestatus",
DisplayName: "Live Status",
Description: "OpenClaw Live Status plugin bot",
})
if appErr != nil {
p.API.LogWarn("Failed to ensure bot user", "error", appErr.Error())
} else {
p.botUserIDLock.Lock()
p.botUserID = botID
p.botUserIDLock.Unlock()
p.API.LogInfo("Plugin bot user ensured", "botUserID", botID)
}
// Start session cleanup goroutine
p.stopCleanup = make(chan struct{})
go p.sessionCleanupLoop()
p.API.LogInfo("OpenClaw Live Status plugin activated")
return nil
}
// sessionCleanupLoop runs periodically to clean up stale and expired sessions.
func (p *Plugin) sessionCleanupLoop() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
staleThreshold := int64(30 * 60 * 1000) // 30 minutes — active sessions with no update
expireThreshold := int64(60 * 60 * 1000) // 1 hour — completed/interrupted sessions
cleaned, expired, err := p.store.CleanStaleSessions(staleThreshold, expireThreshold)
if err != nil {
p.API.LogWarn("Session cleanup error", "error", err.Error())
} else if cleaned > 0 || expired > 0 {
p.API.LogInfo("Session cleanup completed", "stale_marked", cleaned, "expired_deleted", expired)
}
case <-p.stopCleanup:
return
}
}
}
// getBotUserID returns the plugin's bot user ID (thread-safe).
func (p *Plugin) getBotUserID() string {
p.botUserIDLock.RLock()
defer p.botUserIDLock.RUnlock()
return p.botUserID
}
// OnDeactivate is called when the plugin is deactivated.
func (p *Plugin) OnDeactivate() error {
// Stop cleanup goroutine
if p.stopCleanup != nil {
close(p.stopCleanup)
}
// Mark all active sessions as interrupted
sessions, err := p.store.ListAllSessions()
if err != nil {
p.API.LogWarn("Failed to list sessions on deactivate", "error", err.Error())
} else {
for _, s := range sessions {
// Skip sessions already in a terminal state — do not overwrite done/error
if s.Status == "done" || s.Status == "error" {
continue
}
s.Status = "interrupted"
_ = p.store.SaveSession(s.SessionKey, s)
p.broadcastUpdate(s.ChannelID, s)
}
}
p.API.LogInfo("OpenClaw Live Status plugin deactivated")
return nil
}

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#166de0">
<path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-1 14H5V6h14v12zM7 9h2v2H7zm0 4h2v2H7zm4-4h6v2h-6zm0 4h6v2h-6z"/>
</svg>

After

Width:  |  Height:  |  Size: 237 B

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

@@ -0,0 +1,178 @@
package main
import (
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
"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"`
LastUpdateMs int64 `json:"last_update_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
}
// ListAllSessions returns all sessions from the KV store (active and non-active).
func (s *Store) ListAllSessions() ([]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
}
sessions = append(sessions, data)
}
page++
}
return sessions, nil
}
// ListActiveSessions returns only active sessions from the KV store.
func (s *Store) ListActiveSessions() ([]SessionData, error) {
all, err := s.ListAllSessions()
if err != nil {
return nil, err
}
var active []SessionData
for _, sess := range all {
if sess.Status == "active" {
active = append(active, sess)
}
}
return active, nil
}
// CleanStaleSessions marks stale active sessions as interrupted and deletes expired completed sessions.
// staleThresholdMs: active sessions with no update for this long are marked interrupted.
// expireThresholdMs: non-active sessions older than this are deleted from KV.
func (s *Store) CleanStaleSessions(staleThresholdMs, expireThresholdMs int64) (cleaned int, expired int, err error) {
now := time.Now().UnixMilli()
all, err := s.ListAllSessions()
if err != nil {
return 0, 0, err
}
for _, session := range all {
lastUpdate := session.LastUpdateMs
if lastUpdate == 0 {
lastUpdate = session.StartTimeMs
}
if lastUpdate == 0 {
// No timestamps at all (pre-cleanup era orphan) — treat as expired immediately.
// Delete non-active sessions; mark active ones as interrupted so they get
// picked up on the next cleanup cycle with a real timestamp.
if session.Status == "active" {
session.Status = "interrupted"
session.LastUpdateMs = now
_ = s.SaveSession(session.SessionKey, session)
cleaned++
} else {
_ = s.DeleteSession(session.SessionKey)
expired++
}
continue
}
age := now - lastUpdate
if session.Status == "active" && age > staleThresholdMs {
// Mark stale sessions as interrupted
session.Status = "interrupted"
session.LastUpdateMs = now
_ = s.SaveSession(session.SessionKey, session)
cleaned++
} else if session.Status != "active" && age > expireThresholdMs {
// Delete expired completed/interrupted sessions
_ = s.DeleteSession(session.SessionKey)
expired++
}
}
return cleaned, expired, nil
}

View File

@@ -0,0 +1,38 @@
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_json": childrenJSON,
"start_time_ms": data.StartTimeMs,
}
p.API.PublishWebSocketEvent("update", payload, &model.WebsocketBroadcast{
ChannelId: channelID,
})
}

1
plugin/webapp/dist/main.js vendored Normal file

File diff suppressed because one or more lines are too long

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,197 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { LiveStatusData } from '../types';
import StatusLine from './status_line';
const STORAGE_KEY = 'livestatus_widget_pos';
const AUTO_HIDE_DELAY = 5000; // 5s after all sessions complete
const FloatingWidget: React.FC = () => {
const [sessions, setSessions] = useState<Record<string, LiveStatusData>>({});
const [collapsed, setCollapsed] = useState(true);
const [visible, setVisible] = useState(false);
const [position, setPosition] = useState(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) return JSON.parse(saved);
} catch {}
return { right: 20, bottom: 80 };
});
const [dragging, setDragging] = useState(false);
const dragRef = useRef<{ startX: number; startY: number; startRight: number; startBottom: number } | null>(null);
const hideTimerRef = useRef<number | null>(null);
const widgetRef = useRef<HTMLDivElement>(null);
// Subscribe to updates (same pattern as RHS panel)
useEffect(() => {
const key = '__floating_widget__';
if (!window.__livestatus_listeners[key]) {
window.__livestatus_listeners[key] = [];
}
const listener = () => {
setSessions({ ...(window.__livestatus_updates || {}) });
};
window.__livestatus_listeners[key].push(listener);
// Polling fallback
const interval = setInterval(() => {
setSessions(prev => {
const current = window.__livestatus_updates || {};
const prevKeys = Object.keys(prev).sort().join(',');
const currKeys = Object.keys(current).sort().join(',');
if (prevKeys !== currKeys) return { ...current };
for (const k of Object.keys(current)) {
if (current[k] !== prev[k]) return { ...current };
}
return prev;
});
}, 2000);
return () => {
clearInterval(interval);
const listeners = window.__livestatus_listeners[key];
if (listeners) {
const idx = listeners.indexOf(listener);
if (idx >= 0) listeners.splice(idx, 1);
}
};
}, []);
// Auto-show/hide logic
useEffect(() => {
const entries = Object.values(sessions);
const hasActive = entries.some(s => s.status === 'active');
if (hasActive) {
// Clear any pending hide timer
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}
setVisible(true);
} else if (visible && entries.length > 0) {
// All done - hide after delay
if (!hideTimerRef.current) {
hideTimerRef.current = window.setTimeout(() => {
setVisible(false);
setCollapsed(true);
hideTimerRef.current = null;
}, AUTO_HIDE_DELAY);
}
}
}, [sessions, visible]);
// Save position to localStorage
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(position));
} catch {}
}, [position]);
// Drag handlers
const onMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
dragRef.current = {
startX: e.clientX,
startY: e.clientY,
startRight: position.right,
startBottom: position.bottom,
};
setDragging(true);
}, [position]);
useEffect(() => {
if (!dragging) return;
const onMouseMove = (e: MouseEvent) => {
if (!dragRef.current) return;
const dx = dragRef.current.startX - e.clientX;
const dy = dragRef.current.startY - e.clientY;
setPosition({
right: Math.max(0, dragRef.current.startRight + dx),
bottom: Math.max(0, dragRef.current.startBottom + dy),
});
};
const onMouseUp = () => {
setDragging(false);
dragRef.current = null;
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
return () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
}, [dragging]);
if (!visible) return null;
const entries = Object.values(sessions);
const activeSessions = entries.filter(s => s.status === 'active');
const displaySession = activeSessions[0] || entries[entries.length - 1];
const activeCount = activeSessions.length;
// Collapsed = pulsing dot with count
if (collapsed) {
return (
<div
ref={widgetRef}
className="ls-widget-collapsed"
style={{ right: position.right, bottom: position.bottom }}
onClick={() => setCollapsed(false)}
title={`${activeCount} active agent session${activeCount !== 1 ? 's' : ''}`}
>
<span className="ls-live-dot" />
{activeCount > 0 && <span className="ls-widget-count">{activeCount}</span>}
</div>
);
}
// Expanded view
return (
<div
ref={widgetRef}
className="ls-widget-expanded"
style={{ right: position.right, bottom: position.bottom }}
>
<div className="ls-widget-header" onMouseDown={onMouseDown}>
<span className="ls-widget-title">
{activeCount > 0 && <span className="ls-live-dot" />}
Agent Status
{activeCount > 0 && ` (${activeCount})`}
</span>
<button className="ls-widget-collapse-btn" onClick={() => setCollapsed(true)}>_</button>
<button className="ls-widget-close-btn" onClick={() => setVisible(false)}>×</button>
</div>
<div className="ls-widget-body">
{displaySession ? (
<div className="ls-widget-session">
<div className="ls-widget-session-header">
<span className="ls-agent-badge">{displaySession.agent_id}</span>
<span className={`ls-status-badge ls-status-${displaySession.status}`}>
{displaySession.status.toUpperCase()}
</span>
</div>
<div className="ls-widget-lines">
{displaySession.lines.slice(-5).map((line, i) => (
<StatusLine key={i} line={line} />
))}
</div>
</div>
) : (
<div className="ls-widget-empty">No sessions</div>
)}
{activeSessions.length > 1 && (
<div className="ls-widget-more">
+{activeSessions.length - 1} more active
</div>
)}
</div>
</div>
);
};
export default FloatingWidget;

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,215 @@
import React, { useState, useEffect } from 'react';
import TerminalView from './terminal_view';
import { LiveStatusData } from '../types';
/**
* Subscribe to ALL live status updates across all sessions.
* Returns a map of postId -> LiveStatusData, re-rendered on every update.
*/
function useAllStatusUpdates(): Record<string, LiveStatusData> {
const [sessions, setSessions] = useState<Record<string, LiveStatusData>>(() => {
return { ...(window.__livestatus_updates || {}) };
});
useEffect(() => {
// Initial fetch of existing sessions from the plugin API.
// Uses credentials: 'include' so Mattermost session cookies are forwarded.
// The server allows unauthenticated GET /sessions for MM-authenticated users.
fetch('/plugins/com.openclaw.livestatus/api/v1/sessions', {
credentials: 'include',
})
.then((res) => res.json())
.then((fetchedSessions: LiveStatusData[]) => {
if (Array.isArray(fetchedSessions)) {
const updates: Record<string, LiveStatusData> = {};
fetchedSessions.forEach((s) => {
const key = s.post_id || s.session_key;
updates[key] = s;
});
// Merge: WebSocket data (already in window) takes precedence over fetched data
window.__livestatus_updates = {
...updates,
...window.__livestatus_updates,
};
setSessions({ ...window.__livestatus_updates });
}
})
.catch((err) => console.warn('[LiveStatus] Failed to fetch initial sessions:', err));
// Register a global listener that catches all updates
const globalKey = '__rhs_panel__';
if (!window.__livestatus_listeners[globalKey]) {
window.__livestatus_listeners[globalKey] = [];
}
const listener = () => {
setSessions({ ...(window.__livestatus_updates || {}) });
};
window.__livestatus_listeners[globalKey].push(listener);
// Also set up a polling fallback (WebSocket updates are primary)
const interval = setInterval(() => {
const current = window.__livestatus_updates || {};
setSessions((prev) => {
// Only update if something changed
const prevKeys = Object.keys(prev).sort().join(',');
const currKeys = Object.keys(current).sort().join(',');
if (prevKeys !== currKeys) return { ...current };
// Check if any data changed
for (const key of Object.keys(current)) {
if (current[key] !== prev[key]) return { ...current };
}
return prev;
});
}, 2000);
return () => {
clearInterval(interval);
const listeners = window.__livestatus_listeners[globalKey];
if (listeners) {
const idx = listeners.indexOf(listener);
if (idx >= 0) listeners.splice(idx, 1);
}
};
}, []);
return sessions;
}
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`;
}
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);
}
/**
* Single session card within the RHS panel.
*/
const SessionCard: React.FC<{ data: LiveStatusData }> = ({ data }) => {
const [elapsed, setElapsed] = useState(0);
const isActive = data.status === '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]);
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-rhs-card ${statusClass}`}>
<div className="ls-rhs-card-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={20} />
{data.children && data.children.length > 0 && (
<div className="ls-rhs-children">
{data.children.map((child, i) => (
<div key={child.session_key || i} className="ls-rhs-child">
<div className="ls-rhs-child-header">
<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>
</div>
))}
</div>
)}
{data.token_count > 0 && (
<div className="ls-rhs-card-footer">
{formatTokens(data.token_count)} tokens
</div>
)}
</div>
);
};
/**
* RHSPanel — Right-Hand Sidebar component.
* Shows all active agent sessions in a live-updating dashboard.
*/
const RHSPanel: React.FC = () => {
const sessions = useAllStatusUpdates();
const entries = Object.values(sessions);
// Sort: active first, then by start time descending
const sorted = entries.sort((a, b) => {
if (a.status === 'active' && b.status !== 'active') return -1;
if (b.status === 'active' && a.status !== 'active') return 1;
return (b.start_time_ms || 0) - (a.start_time_ms || 0);
});
const activeSessions = sorted.filter((s) => s.status === 'active');
const completedSessions = sorted.filter((s) => s.status !== 'active');
return (
<div className="ls-rhs-panel">
<div className="ls-rhs-summary">
<span className="ls-rhs-count">
{activeSessions.length > 0 ? (
<>
<span className="ls-live-dot" />
{activeSessions.length} active
</>
) : (
'No active sessions'
)}
</span>
</div>
{activeSessions.length === 0 && completedSessions.length === 0 && (
<div className="ls-rhs-empty">
<div className="ls-rhs-empty-icon"></div>
<div className="ls-rhs-empty-text">
No agent activity yet.
<br />
Status will appear here when an agent starts working.
</div>
</div>
)}
{activeSessions.map((data) => (
<SessionCard key={data.post_id || data.session_key} data={data} />
))}
{completedSessions.length > 0 && (
<div className="ls-rhs-section">
<div className="ls-rhs-section-title">Recent</div>
{completedSessions.slice(0, 5).map((data) => (
<SessionCard key={data.post_id || data.session_key} data={data} />
))}
</div>
)}
</div>
);
};
export default RHSPanel;

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;

111
plugin/webapp/src/index.tsx Normal file
View File

@@ -0,0 +1,111 @@
import React from 'react';
import { PluginRegistry, WebSocketPayload, LiveStatusData } from './types';
import LiveStatusPost from './components/live_status_post';
import RHSPanel from './components/rhs_panel';
import FloatingWidget from './components/floating_widget';
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 RHS sidebar component
const rhsResult = registry.registerRightHandSidebarComponent({
component: RHSPanel,
title: 'Agent Status',
});
// Add channel header button to toggle the RHS panel
const toggleAction = rhsResult.toggleRHSPlugin;
registry.registerChannelHeaderButtonAction({
icon: React.createElement('i', {
className: 'icon icon-cog-outline',
style: { fontSize: '18px' },
}),
action: () => {
store.dispatch(toggleAction);
},
dropdownText: 'Agent Status',
tooltipText: 'Toggle Agent Status panel',
});
// Register floating widget as root component (always rendered)
registry.registerRootComponent(FloatingWidget);
// 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;
// Evict completed sessions from the update cache after 60s to prevent unbounded growth
if (data.status === 'done' || data.status === 'error' || data.status === 'interrupted') {
setTimeout(() => {
delete window.__livestatus_updates[data.post_id];
}, 60000);
}
// Notify post-specific listeners
const listeners = window.__livestatus_listeners[data.post_id];
if (listeners) {
listeners.forEach((fn) => fn(update));
}
// Notify RHS panel global listener
const rhsListeners = window.__livestatus_listeners['__rhs_panel__'];
if (rhsListeners) {
rhsListeners.forEach((fn) => fn(update));
}
// Notify floating widget listener
const widgetListeners = window.__livestatus_listeners['__floating_widget__'];
if (widgetListeners) {
widgetListeners.forEach((fn) => fn(update));
}
});
}
uninitialize(): void {
// Clear global listener and update stores to prevent accumulation across reloads
window.__livestatus_listeners = {};
window.__livestatus_updates = {};
// Unregister the custom post type component if it was registered
if (this.postTypeComponentId) {
// registry is not available here — Mattermost framework cleans up on deactivate.
// Clearing postTypeComponentId prevents stale references.
this.postTypeComponentId = null;
}
}
}
// 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,445 @@
/* 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));
}
/* ========= RHS Panel Styles ========= */
.ls-rhs-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
background: var(--center-channel-bg, #fff);
}
.ls-rhs-summary {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid var(--center-channel-color-08, rgba(0, 0, 0, 0.08));
font-size: 13px;
font-weight: 600;
color: var(--center-channel-color, #3d3c40);
}
.ls-rhs-count {
display: flex;
align-items: center;
gap: 6px;
}
.ls-rhs-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
}
.ls-rhs-empty-icon {
font-size: 32px;
margin-bottom: 12px;
}
.ls-rhs-empty-text {
color: var(--center-channel-color-48, rgba(0, 0, 0, 0.48));
font-size: 13px;
line-height: 1.5;
}
.ls-rhs-card {
margin: 8px 12px;
border: 1px solid var(--center-channel-color-08, rgba(0, 0, 0, 0.08));
border-radius: 6px;
overflow: hidden;
}
.ls-rhs-card.ls-status-active {
border-color: rgba(76, 175, 80, 0.3);
}
.ls-rhs-card-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: var(--center-channel-color-04, rgba(0, 0, 0, 0.04));
border-bottom: 1px solid var(--center-channel-color-08, rgba(0, 0, 0, 0.08));
}
.ls-rhs-card .ls-terminal {
max-height: 250px;
font-size: 11px;
padding: 6px 10px;
}
.ls-rhs-card-footer {
padding: 4px 10px 6px;
font-size: 11px;
color: var(--center-channel-color-48, rgba(0, 0, 0, 0.48));
font-family: 'SFMono-Regular', Consolas, monospace;
border-top: 1px solid var(--center-channel-color-08, rgba(0, 0, 0, 0.08));
}
.ls-rhs-children {
padding: 4px 10px;
border-top: 1px solid var(--center-channel-color-08, rgba(0, 0, 0, 0.08));
}
.ls-rhs-child {
padding: 4px 0;
}
.ls-rhs-child-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
}
.ls-rhs-section {
margin-top: 4px;
}
.ls-rhs-section-title {
padding: 8px 16px 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--center-channel-color-48, rgba(0, 0, 0, 0.48));
}
/* ========= Floating Widget Styles ========= */
.ls-widget-collapsed {
position: fixed;
z-index: 9999;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
padding: 8px;
background: var(--center-channel-bg, #fff);
border: 1px solid var(--center-channel-color-16, rgba(0, 0, 0, 0.16));
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition: transform 0.2s;
}
.ls-widget-collapsed:hover {
transform: scale(1.1);
}
.ls-widget-collapsed .ls-live-dot {
width: 12px;
height: 12px;
}
.ls-widget-count {
position: absolute;
top: -4px;
right: -4px;
background: #f44336;
color: #fff;
font-size: 10px;
font-weight: 700;
width: 16px;
height: 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.ls-widget-expanded {
position: fixed;
z-index: 9999;
width: 320px;
max-height: 300px;
background: var(--center-channel-bg, #fff);
border: 1px solid var(--center-channel-color-16, rgba(0, 0, 0, 0.16));
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.ls-widget-header {
display: flex;
align-items: center;
padding: 8px 10px;
background: var(--center-channel-color-04, rgba(0, 0, 0, 0.04));
border-bottom: 1px solid var(--center-channel-color-08, rgba(0, 0, 0, 0.08));
cursor: grab;
user-select: none;
}
.ls-widget-header:active {
cursor: grabbing;
}
.ls-widget-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
flex: 1;
}
.ls-widget-collapse-btn,
.ls-widget-close-btn {
background: none;
border: none;
cursor: pointer;
font-size: 14px;
color: var(--center-channel-color-56, rgba(0, 0, 0, 0.56));
padding: 2px 6px;
border-radius: 3px;
}
.ls-widget-collapse-btn:hover,
.ls-widget-close-btn:hover {
background: var(--center-channel-color-08, rgba(0, 0, 0, 0.08));
}
.ls-widget-body {
padding: 8px;
overflow-y: auto;
flex: 1;
}
.ls-widget-session {
margin-bottom: 4px;
}
.ls-widget-session-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.ls-widget-lines {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 11px;
line-height: 1.5;
background: var(--center-channel-color-04, rgba(0, 0, 0, 0.04));
border-radius: 4px;
padding: 6px 8px;
max-height: 150px;
overflow-y: auto;
}
.ls-widget-empty {
color: var(--center-channel-color-48, rgba(0, 0, 0, 0.48));
font-size: 12px;
text-align: center;
padding: 12px;
}
.ls-widget-more {
font-size: 11px;
color: var(--center-channel-color-48, rgba(0, 0, 0, 0.48));
text-align: center;
padding: 4px;
}

View File

@@ -0,0 +1,59 @@
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;
registerRightHandSidebarComponent(opts: { component: any; title: string }): {
id: string;
showRHSPlugin: any;
hideRHSPlugin: any;
toggleRHSPlugin: any;
};
registerAppBarComponent(opts: {
iconUrl: string;
tooltipText: string;
rhsComponent?: any;
rhsTitle?: string;
action?: () => void;
}): string;
registerChannelHeaderButtonAction(opts: {
icon: any;
action: () => void;
dropdownText: string;
tooltipText: string;
}): string;
registerRootComponent(component: React.ComponentType): string;
}
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

@@ -1,48 +1,11 @@
# Live Status Skill
**Use this tool to report real-time progress updates to Mattermost.**
It allows you to create a "Live Log" post that you update in-place, reducing chat spam.
Status updates are automatic. You do not need to call live-status manually.
## Usage
The Live Status v4 daemon watches your transcript in real-time and posts
progress updates to Mattermost automatically. Focus on your task.
### 1. Initialize (Start of Task)
If you receive `live-status CLI is deprecated` warnings, ignore them.
The underlying infrastructure is handling visibility for you.
Create a new status post. It will print the `POST_ID`.
**Required:** Pass the `CHANNEL_ID` if known (otherwise it defaults to system channel).
```bash
live-status create <CHANNEL_ID> "🚀 **Task Started:** Initializing..."
```
**Output:** `p6...` (The Post ID)
### 2. Update (During Task)
Update the post with new log lines. Use a code block for logs.
```bash
live-status update <POST_ID> "🚀 **Task Started:** Initializing...
\`\`\`
[10:00] Checking files... OK
[10:01] Downloading assets...
\`\`\`"
```
### 3. Complete (End of Task)
Mark as done.
```bash
live-status update <POST_ID> "✅ **Task Complete.**
\`\`\`
[10:00] Checking files... OK
[10:01] Downloading assets... Done.
[10:05] Verifying... Success.
\`\`\`"
```
## Protocol
- **Always** capture the `POST_ID` from the `create` command.
- **Always** append to the previous log (maintain history).
- **Use Code Blocks** for technical logs.
For advanced use (manual status boxes), see README.md in the live-status project.

143
src/circuit-breaker.js Normal file
View File

@@ -0,0 +1,143 @@
'use strict';
/**
* circuit-breaker.js — Circuit breaker for API resilience.
*
* States:
* CLOSED — Normal operation. Failures tracked.
* OPEN — Too many failures. Calls rejected immediately.
* HALF_OPEN — Cooldown expired. One probe call allowed.
*
* Transition rules:
* CLOSED -> OPEN: failures >= threshold
* OPEN -> HALF_OPEN: cooldown expired
* HALF_OPEN -> CLOSED: probe succeeds
* HALF_OPEN -> OPEN: probe fails
*/
const STATE = {
CLOSED: 'closed',
OPEN: 'open',
HALF_OPEN: 'half_open',
};
class CircuitBreaker {
/**
* @param {object} opts
* @param {number} opts.threshold - Consecutive failures to open (default 5)
* @param {number} opts.cooldownMs - Milliseconds before half-open probe (default 30000)
* @param {Function} [opts.onStateChange] - Called with (newState, oldState)
* @param {object} [opts.logger] - Optional logger
*/
constructor(opts = {}) {
this.threshold = opts.threshold || 5;
this.cooldownMs = opts.cooldownMs || 30000;
this.onStateChange = opts.onStateChange || null;
this.logger = opts.logger || null;
this.state = STATE.CLOSED;
this.failures = 0;
this.openedAt = null;
this.lastError = null;
}
/**
* Execute a function through the circuit breaker.
* Throws CircuitOpenError if the circuit is open.
* @param {Function} fn - Async function to execute
* @returns {Promise<*>}
*/
async execute(fn) {
if (this.state === STATE.OPEN) {
const elapsed = Date.now() - this.openedAt;
if (elapsed >= this.cooldownMs) {
this._transition(STATE.HALF_OPEN);
} else {
throw new CircuitOpenError(
`Circuit open (${Math.ceil((this.cooldownMs - elapsed) / 1000)}s remaining)`,
this.lastError,
);
}
}
try {
const result = await fn();
this._onSuccess();
return result;
} catch (err) {
this._onFailure(err);
throw err;
}
}
_onSuccess() {
if (this.state === STATE.HALF_OPEN) {
this._transition(STATE.CLOSED);
}
this.failures = 0;
this.lastError = null;
}
_onFailure(err) {
this.lastError = err;
this.failures++;
if (this.state === STATE.HALF_OPEN) {
// Probe failed — reopen
this.openedAt = Date.now();
this._transition(STATE.OPEN);
} else if (this.state === STATE.CLOSED && this.failures >= this.threshold) {
this.openedAt = Date.now();
this._transition(STATE.OPEN);
}
}
_transition(newState) {
const oldState = this.state;
this.state = newState;
if (newState === STATE.CLOSED) {
this.failures = 0;
this.openedAt = null;
}
if (this.logger) {
this.logger.warn({ from: oldState, to: newState }, 'Circuit breaker state change');
}
if (this.onStateChange) {
this.onStateChange(newState, oldState);
}
}
getState() {
return this.state;
}
getMetrics() {
return {
state: this.state,
failures: this.failures,
threshold: this.threshold,
openedAt: this.openedAt,
lastError: this.lastError ? this.lastError.message : null,
};
}
reset() {
this.state = STATE.CLOSED;
this.failures = 0;
this.openedAt = null;
this.lastError = null;
}
}
class CircuitOpenError extends Error {
constructor(message, cause) {
super(message);
this.name = 'CircuitOpenError';
this.cause = cause;
}
}
module.exports = { CircuitBreaker, CircuitOpenError, STATE };

122
src/config.js Normal file
View File

@@ -0,0 +1,122 @@
'use strict';
/**
* config.js — Centralized env-var config with validation.
* All config is read from environment variables.
* Throws on missing required variables at startup.
*/
function getEnv(name, defaultValue, required = false) {
const val = process.env[name];
if (val === undefined || val === '') {
if (required) {
throw new Error(`Required environment variable ${name} is not set`);
}
return defaultValue;
}
return val;
}
function getEnvInt(name, defaultValue, required = false) {
const val = getEnv(name, undefined, required);
if (val === undefined) return defaultValue;
const n = parseInt(val, 10);
if (isNaN(n)) throw new Error(`Environment variable ${name} must be an integer, got: ${val}`);
return n;
}
function getEnvBool(name, defaultValue) {
const val = process.env[name];
if (val === undefined || val === '') return defaultValue;
return val === '1' || val.toLowerCase() === 'true' || val.toLowerCase() === 'yes';
}
/**
* Build and validate the config object.
* Called once at startup; throws on invalid config.
*/
function buildConfig() {
const config = {
// Mattermost API
mm: {
token: getEnv('MM_BOT_TOKEN', null, true),
baseUrl: getEnv('MM_BASE_URL', 'https://slack.solio.tech'),
maxSockets: getEnvInt('MM_MAX_SOCKETS', 4),
botUserId: getEnv('MM_BOT_USER_ID', null),
},
// Transcript directory (OpenClaw agents)
transcriptDir: getEnv('TRANSCRIPT_DIR', '/root/.openclaw/agents'),
// Timing
throttleMs: getEnvInt('THROTTLE_MS', 500),
idleTimeoutS: getEnvInt('IDLE_TIMEOUT_S', 60),
sessionPollMs: getEnvInt('SESSION_POLL_MS', 2000),
// Limits
maxActiveSessions: getEnvInt('MAX_ACTIVE_SESSIONS', 20),
maxMessageChars: getEnvInt('MAX_MESSAGE_CHARS', 15000),
maxStatusLines: getEnvInt('MAX_STATUS_LINES', 20),
maxRetries: getEnvInt('MAX_RETRIES', 3),
// Circuit breaker
circuitBreakerThreshold: getEnvInt('CIRCUIT_BREAKER_THRESHOLD', 5),
circuitBreakerCooldownS: getEnvInt('CIRCUIT_BREAKER_COOLDOWN_S', 30),
// Health check
healthPort: getEnvInt('HEALTH_PORT', 9090),
// Logging
logLevel: getEnv('LOG_LEVEL', 'info'),
// PID file
pidFile: getEnv('PID_FILE', '/tmp/status-watcher.pid'),
// Offset persistence
offsetFile: getEnv('OFFSET_FILE', '/tmp/status-watcher-offsets.json'),
// Optional external tool labels override
toolLabelsFile: getEnv('TOOL_LABELS_FILE', null),
// Fallback channel for non-MM sessions (null = skip)
defaultChannel: getEnv('DEFAULT_CHANNEL', null),
// Feature flags
enableFsWatch: getEnvBool('ENABLE_FS_WATCH', true),
// Mattermost plugin integration (optional)
// When configured, updates are sent to the plugin instead of using PUT on posts
plugin: {
url: getEnv('PLUGIN_URL', null), // e.g. https://slack.solio.tech/plugins/com.openclaw.livestatus
secret: getEnv('PLUGIN_SECRET', null),
enabled: getEnvBool('PLUGIN_ENABLED', true),
detectIntervalMs: getEnvInt('PLUGIN_DETECT_INTERVAL_MS', 60000),
},
};
// Validate MM base URL
try {
new URL(config.mm.baseUrl);
} catch (_e) {
throw new Error(`MM_BASE_URL is not a valid URL: ${config.mm.baseUrl}`);
}
return config;
}
// Singleton — built once, exported
let _config = null;
function getConfig() {
if (!_config) {
_config = buildConfig();
}
return _config;
}
// Allow resetting config in tests
function resetConfig() {
_config = null;
}
module.exports = { getConfig, resetConfig, buildConfig };

118
src/health.js Normal file
View File

@@ -0,0 +1,118 @@
'use strict';
/**
* health.js — HTTP health endpoint + metrics.
*
* GET /health -> JSON { status, activeSessions, uptime, lastError, metrics }
* GET /metrics -> JSON { detailed metrics }
*/
/* eslint-disable no-console */
const http = require('http');
class HealthServer {
/**
* @param {object} opts
* @param {number} opts.port - Port to listen on (0 = disabled)
* @param {Function} opts.getMetrics - Callback that returns metrics object
* @param {object} [opts.logger] - pino logger
*/
constructor(opts) {
this.port = opts.port;
this.getMetrics = opts.getMetrics;
this.logger = opts.logger || null;
this.server = null;
this.startTime = Date.now();
}
start() {
if (this.port === 0) {
if (this.logger) this.logger.info('Health server disabled (port=0)');
return Promise.resolve();
}
return new Promise((resolve, reject) => {
this.server = http.createServer((req, res) => {
this._handleRequest(req, res);
});
this.server.on('error', (err) => {
if (this.logger) {
this.logger.error({ err }, 'Health server error');
} else {
console.error('Health server error:', err.message);
}
reject(err);
});
this.server.listen(this.port, '127.0.0.1', () => {
if (this.logger) {
this.logger.info({ port: this.port }, 'Health server listening');
}
resolve();
});
});
}
stop() {
return new Promise((resolve) => {
if (!this.server) {
resolve();
return;
}
this.server.close(() => resolve());
});
}
_handleRequest(req, res) {
const url = new URL(req.url, `http://localhost:${this.port}`);
if (req.method !== 'GET') {
res.writeHead(405, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
let body;
switch (url.pathname) {
case '/health':
body = this._buildHealthResponse();
break;
case '/metrics':
body = this._buildMetricsResponse();
break;
default:
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not found' }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body, null, 2));
}
_buildHealthResponse() {
const metrics = this.getMetrics();
const status = metrics.circuit && metrics.circuit.state === 'open' ? 'degraded' : 'healthy';
return {
status,
uptime: Math.floor((Date.now() - this.startTime) / 1000),
activeSessions: metrics.activeSessions || 0,
lastError: metrics.lastError || null,
metrics: {
updates_sent: metrics.updatesSent || 0,
updates_failed: metrics.updatesFailed || 0,
circuit_state: metrics.circuit ? metrics.circuit.state : 'unknown',
queue_depth: metrics.queueDepth || 0,
},
};
}
_buildMetricsResponse() {
return this.getMetrics();
}
}
module.exports = { HealthServer };

View File

@@ -1,114 +1,383 @@
#!/usr/bin/env node
const https = require('http'); // Using http for mattermost:8065 (no ssl inside docker network)
const _fs = require('fs');
/* eslint-disable no-console */
// --- HELPER: PARSE ARGS ---
const https = require('https');
const http = require('http');
const fs = require('fs');
const path = require('path');
// --- DEPRECATION WARNING ---
// In v4, live-status CLI is deprecated. The status-watcher daemon handles
// all updates automatically by tailing JSONL transcripts. You do not need
// to call this tool manually. It remains available for backward compatibility.
if (process.stderr.isTTY) {
console.error('NOTE: live-status CLI is deprecated as of v4. Status updates are now automatic.');
}
// --- PARSE ARGS ---
const args = process.argv.slice(2);
let command = null;
let options = {};
let otherArgs = [];
const options = {};
const otherArgs = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === '--channel') {
options.channel = args[i + 1];
i++; // Skip next arg (the channel ID)
} else if (!command && (args[i] === 'create' || args[i] === 'update')) {
command = args[i];
const arg = args[i]; // eslint-disable-line security/detect-object-injection
const next = args[i + 1]; // eslint-disable-line security/detect-object-injection
if (arg === '--channel' && next) {
options.channel = next;
i++;
} else if (arg === '--reply-to' && next) {
options.replyTo = next;
i++;
} else if (arg === '--agent' && next) {
options.agent = next;
i++;
} else if (arg === '--token' && next) {
options.token = next;
i++;
} else if (arg === '--host' && next) {
options.host = next;
i++;
} else if (arg === '--rich') {
options.rich = true;
} else if (
!command &&
['create', 'update', 'complete', 'error', 'delete', 'start-watcher', 'stop-watcher'].includes(
arg,
)
) {
command = arg;
} else {
otherArgs.push(args[i]);
otherArgs.push(arg);
}
}
// --- CONFIGURATION (DYNAMIC) ---
// --- LOAD CONFIG ---
function loadConfig() {
const searchPaths = [
process.env.OPENCLAW_CONFIG_DIR && path.join(process.env.OPENCLAW_CONFIG_DIR, 'openclaw.json'),
process.env.XDG_CONFIG_HOME && path.join(process.env.XDG_CONFIG_HOME, 'openclaw.json'),
path.join(process.env.HOME || '/root', '.openclaw', 'openclaw.json'),
'/home/node/.openclaw/openclaw.json',
].filter(Boolean);
for (const p of searchPaths) {
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
return JSON.parse(fs.readFileSync(p, 'utf8'));
} catch (_e) {
/* file not found or invalid JSON — try next path */
}
}
return null;
}
function resolveToken(config) {
if (options.token) return options.token;
if (process.env.MM_BOT_TOKEN) return process.env.MM_BOT_TOKEN;
if (!config) return null;
const mm = config.mattermost || (config.channels && config.channels.mattermost) || {};
const accounts = mm.accounts || {};
if (options.agent) {
try {
const mapPath = path.join(__dirname, 'agent-accounts.json');
const agentMap = JSON.parse(fs.readFileSync(mapPath, 'utf8'));
// eslint-disable-next-line security/detect-object-injection
const accName = agentMap[options.agent];
// eslint-disable-next-line security/detect-object-injection
if (accName && accounts[accName] && accounts[accName].botToken) {
// eslint-disable-next-line security/detect-object-injection
return accounts[accName].botToken;
}
} catch (_e) {
/* agent-accounts.json not found or agent not mapped */
}
}
if (accounts.default && accounts.default.botToken) return accounts.default.botToken;
for (const acc of Object.values(accounts)) {
if (acc.botToken) return acc.botToken;
}
return null;
}
function resolveHost(config) {
if (options.host) return options.host;
if (process.env.MM_HOST) return process.env.MM_HOST;
if (config) {
const mm = config.mattermost || (config.channels && config.channels.mattermost) || {};
const baseUrl = mm.baseUrl || '';
if (baseUrl) {
try {
return new URL(baseUrl).hostname;
} catch (_e) {
/* invalid URL */
}
}
}
return 'localhost';
}
function resolvePort(config) {
if (process.env.MM_PORT) return parseInt(process.env.MM_PORT, 10);
if (config) {
const mm = config.mattermost || (config.channels && config.channels.mattermost) || {};
const baseUrl = mm.baseUrl || '';
if (baseUrl) {
try {
const url = new URL(baseUrl);
return url.port ? parseInt(url.port, 10) : url.protocol === 'https:' ? 443 : 80;
} catch (_e) {
/* invalid URL — use default port */
}
}
}
return 443;
}
function resolveProtocol(config) {
if (config) {
const mm = config.mattermost || (config.channels && config.channels.mattermost) || {};
if ((mm.baseUrl || '').startsWith('http://')) return 'http';
}
return 'https';
}
// --- BUILD CONFIG ---
const ocConfig = loadConfig();
const CONFIG = {
host: 'mattermost',
port: 8065,
token: 'DEFAULT_TOKEN_PLACEHOLDER', // Set via install.sh wizard
// Priority: 1. CLI Flag, 2. Env Var, 3. Hardcoded Fallback (Project-0)
channel_id:
options.channel ||
process.env.MM_CHANNEL_ID ||
process.env.CHANNEL_ID ||
'obzja4hb8pd85xk45xn4p31jye',
host: resolveHost(ocConfig),
port: resolvePort(ocConfig),
protocol: resolveProtocol(ocConfig),
token: resolveToken(ocConfig),
channel_id: options.channel || process.env.MM_CHANNEL_ID || process.env.CHANNEL_ID,
};
// --- HELPER: HTTP REQUEST ---
function request(method, path, data) {
if (!CONFIG.token) {
console.error('Error: No bot token found.');
console.error(' Set MM_BOT_TOKEN, use --token, or configure openclaw.json');
process.exit(1);
}
// --- HTTP REQUEST ---
function request(method, apiPath, data) {
const transport = CONFIG.protocol === 'https' ? https : http;
return new Promise((resolve, reject) => {
const options = {
hostname: CONFIG.host,
port: CONFIG.port,
path: '/api/v4' + path,
method: method,
headers: {
Authorization: `Bearer ${CONFIG.token}`,
'Content-Type': 'application/json',
const req = transport.request(
{
hostname: CONFIG.host,
port: CONFIG.port,
path: '/api/v4' + apiPath,
method,
headers: { Authorization: `Bearer ${CONFIG.token}`, 'Content-Type': 'application/json' },
},
};
const req = https.request(options, (res) => {
let body = '';
res.on('data', (chunk) => (body += chunk));
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
resolve(JSON.parse(body));
} catch (e) {
resolve(body);
(res) => {
let body = '';
res.on('data', (chunk) => (body += chunk));
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
resolve(JSON.parse(body));
} catch (_e) {
resolve(body);
}
} else {
let msg = `HTTP ${res.statusCode}`;
try {
msg = JSON.parse(body).message || msg;
} catch (_e) {
/* use default msg */
}
reject(new Error(msg));
}
} else {
reject(new Error(`Request Failed (${res.statusCode}): ${body}`));
}
});
});
});
},
);
req.on('error', (e) => reject(e));
if (data) req.write(JSON.stringify(data));
req.end();
});
}
// --- RICH ATTACHMENT HELPERS ---
const RICH_STYLES = {
create: { color: '#FFA500', prefix: '⏳', status: '🔄 Running' },
update: { color: '#FFA500', prefix: '🔄', status: '🔄 Running' },
complete: { color: '#36A64F', prefix: '✅', status: '✅ Complete' },
error: { color: '#DC3545', prefix: '❌', status: '❌ Error' },
};
function buildAttachment(cmd, text) {
const style = RICH_STYLES[cmd] || RICH_STYLES.update; // eslint-disable-line security/detect-object-injection
const agentName = options.agent || 'unknown';
// Split text: first line = title, rest = log body
const lines = text.split('\n');
const title = `${style.prefix} ${lines[0]}`;
const body = lines.length > 1 ? lines.slice(1).join('\n') : '';
return {
color: style.color,
title: title,
text: body || undefined,
fields: [
{ short: true, title: 'Agent', value: agentName },
{ short: true, title: 'Status', value: style.status },
],
};
}
// --- COMMANDS ---
async function createPost(text) {
async function createPost(text, cmd) {
if (!CONFIG.channel_id) {
console.error('Error: Channel ID required. Use --channel <id> or set MM_CHANNEL_ID.');
process.exit(1);
}
if (!text || !text.trim()) {
console.error('Error: Message text is required for create.');
process.exit(1);
}
try {
const result = await request('POST', '/posts', {
channel_id: CONFIG.channel_id,
message: text,
});
const agentName = options.agent || 'unknown';
const statusMap = { create: 'running', update: 'running', complete: 'complete', error: 'error' };
const cmdKey = cmd || 'create';
let payload;
if (options.rich) {
payload = {
channel_id: CONFIG.channel_id,
message: text,
type: 'custom_livestatus',
props: {
attachments: [buildAttachment(cmdKey, text)],
livestatus: {
agent_id: agentName,
status: statusMap[cmdKey] || 'running', // eslint-disable-line security/detect-object-injection
lines: text.split('\n'),
},
},
};
} else {
payload = {
channel_id: CONFIG.channel_id,
message: text,
type: 'custom_livestatus',
props: {
livestatus: {
agent_id: agentName,
status: statusMap[cmdKey] || 'running', // eslint-disable-line security/detect-object-injection
lines: text.split('\n'),
},
},
};
}
if (options.replyTo) payload.root_id = options.replyTo;
const result = await request('POST', '/posts', payload);
console.log(result.id);
} catch (e) {
console.error(`Error creating post in channel ${CONFIG.channel_id}:`, e.message);
console.error('Error (create):', e.message);
process.exit(1);
}
}
async function updatePost(postId, text) {
async function updatePost(postId, text, cmd) {
if (!postId) {
console.error('Error: Post ID is required for update.');
process.exit(1);
}
if (!text || !text.trim()) {
console.error('Error: Message text is required for update.');
process.exit(1);
}
try {
const current = await request('GET', `/posts/${postId}`);
await request('PUT', `/posts/${postId}`, {
id: postId,
message: text,
props: current.props,
});
const agentName = options.agent || 'unknown';
const statusMap = { create: 'running', update: 'running', complete: 'complete', error: 'error' };
const cmdKey = cmd || 'update';
const livestatusProps = {
agent_id: agentName,
status: statusMap[cmdKey] || 'running', // eslint-disable-line security/detect-object-injection
lines: text.split('\n'),
};
if (options.rich) {
await request('PUT', `/posts/${postId}`, {
id: postId,
message: text,
props: { attachments: [buildAttachment(cmdKey, text)], livestatus: livestatusProps },
});
} else {
await request('PUT', `/posts/${postId}`, {
id: postId,
message: text,
props: { livestatus: livestatusProps },
});
}
console.log('updated');
} catch (e) {
console.error('Error updating post:', e.message);
console.error('Error (update):', e.message);
process.exit(1);
}
}
async function deletePost(postId) {
if (!postId) {
console.error('Error: Post ID is required for delete.');
process.exit(1);
}
try {
await request('DELETE', `/posts/${postId}`);
console.log('deleted');
} catch (e) {
console.error('Error (delete):', e.message);
process.exit(1);
}
}
// --- CLI ROUTER ---
if (command === 'create') {
const text = otherArgs.join(' ');
createPost(text);
if (command === 'start-watcher' || command === 'stop-watcher') {
// Pass-through to watcher-manager.js
const { spawnSync } = require('child_process');
const watcherPath = path.join(__dirname, 'watcher-manager.js');
const subCmd = command === 'start-watcher' ? 'start' : 'stop';
const result = spawnSync(process.execPath, [watcherPath, subCmd], {
stdio: 'inherit',
env: process.env,
});
process.exit(result.status || 0);
} else if (command === 'create') {
createPost(otherArgs.join(' '), 'create');
} else if (command === 'update') {
const id = otherArgs[0];
const text = otherArgs.slice(1).join(' ');
updatePost(id, text);
updatePost(otherArgs[0], otherArgs.slice(1).join(' '), 'update');
} else if (command === 'complete') {
updatePost(otherArgs[0], otherArgs.slice(1).join(' '), 'complete');
} else if (command === 'error') {
updatePost(otherArgs[0], otherArgs.slice(1).join(' '), 'error');
} else if (command === 'delete') {
deletePost(otherArgs[0]);
} else {
console.log('Usage: live-status [--channel ID] create <text>');
console.log(' live-status update <id> <text>');
console.log('Usage:');
console.log(' live-status [options] create <text>');
console.log(' live-status [options] update <id> <text>');
console.log(' live-status [options] complete <id> <text>');
console.log(' live-status [options] error <id> <text>');
console.log(' live-status [options] delete <id>');
console.log(' live-status start-watcher (pass-through to watcher-manager start)');
console.log(' live-status stop-watcher (pass-through to watcher-manager stop)');
console.log('');
console.log('Options:');
console.log(' --rich Use rich message attachments (colored cards)');
console.log(' --channel ID Target channel');
console.log(' --reply-to ID Post as thread reply');
console.log(' --agent NAME Use bot token mapped to this agent');
console.log(' --token TOKEN Explicit bot token (overrides all)');
console.log(' --host HOST Mattermost hostname');
console.log('');
console.log('Rich mode colors:');
console.log(' create/update → Orange (running)');
console.log(' complete → Green (done)');
console.log(' error → Red (failed)');
process.exit(1);
}

43
src/logger.js Normal file
View File

@@ -0,0 +1,43 @@
'use strict';
/**
* logger.js — pino wrapper with default config.
* Singleton logger; supports session-scoped child loggers.
*/
const pino = require('pino');
let _logger = null;
function getLogger() {
if (!_logger) {
// Get log level from env directly (avoid circular dep with config.js)
const rawLevel = process.env.LOG_LEVEL;
const validLevels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'silent'];
const level = rawLevel && validLevels.includes(rawLevel) ? rawLevel : 'info';
_logger = pino({
level,
base: { pid: process.pid },
timestamp: pino.stdTimeFunctions.isoTime,
});
}
return _logger;
}
/**
* Create a child logger scoped to a session.
* @param {string} sessionKey
* @returns {pino.Logger}
*/
function sessionLogger(sessionKey) {
return getLogger().child({ sessionKey });
}
/**
* Reset the logger singleton (for tests).
*/
function resetLogger() {
_logger = null;
}
module.exports = { getLogger, sessionLogger, resetLogger };

171
src/plugin-client.js Normal file
View File

@@ -0,0 +1,171 @@
'use strict';
/**
* plugin-client.js — HTTP client for the OpenClaw Live Status Mattermost plugin.
*
* When the plugin is available, the daemon sends structured data to the plugin
* instead of using PUT to update Mattermost posts directly. This eliminates
* the "(edited)" label, enables WebSocket real-time rendering, and avoids
* Mattermost's post API rate limits during streaming updates.
*
* Fallback: if the plugin is unavailable, watcher-manager falls back to the
* standard REST API (PUT post updates via StatusBox).
*/
var https = require('https');
var http = require('http');
var DEFAULT_TIMEOUT_MS = 5000;
var DEFAULT_MAX_SOCKETS = 4;
function PluginClient(opts) {
this.pluginUrl = opts.pluginUrl; // e.g. https://slack.solio.tech/plugins/com.openclaw.livestatus
this.secret = opts.secret;
this.logger = opts.logger || null;
this.timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
var parsedUrl = new URL(this.pluginUrl);
this.hostname = parsedUrl.hostname;
this.port = parsedUrl.port
? parseInt(parsedUrl.port, 10)
: parsedUrl.protocol === 'https:' ? 443 : 80;
this.basePath = parsedUrl.pathname.replace(/\/$/, '');
this.isHttps = parsedUrl.protocol === 'https:';
this.agent = new (this.isHttps ? https : http).Agent({
keepAlive: true,
maxSockets: opts.maxSockets || DEFAULT_MAX_SOCKETS,
});
}
/**
* Check if the plugin is healthy and available.
* @returns {Promise<boolean>}
*/
PluginClient.prototype.isHealthy = function () {
return this._request('GET', '/api/v1/health', null)
.then(function (data) {
return data && data.status === 'healthy';
})
.catch(function () {
return false;
});
};
/**
* Create a new session via the plugin (returns post_id).
* @param {string} sessionKey
* @param {string} channelId
* @param {string} rootId
* @param {string} agentId
* @returns {Promise<string>} post_id
*/
/**
* @param {string} botUserId - Mattermost bot user ID to author the post
*/
PluginClient.prototype.setBotUserId = function (botUserId) {
this.botUserId = botUserId;
};
PluginClient.prototype.createSession = function (sessionKey, channelId, rootId, agentId) {
var body = {
session_key: sessionKey,
channel_id: channelId,
root_id: rootId || '',
agent_id: agentId,
};
if (this.botUserId) {
body.bot_user_id = this.botUserId;
}
return this._request('POST', '/api/v1/sessions', body).then(function (data) {
return data.post_id;
});
};
/**
* Update a session with new status data (WebSocket broadcast, no post edit).
* @param {string} sessionKey
* @param {object} data - { status, lines, elapsed_ms, token_count, children, start_time_ms }
* @returns {Promise<void>}
*/
PluginClient.prototype.updateSession = function (sessionKey, data) {
var encodedKey = encodeURIComponent(sessionKey);
return this._request('PUT', '/api/v1/sessions/' + encodedKey, data).then(function () {});
};
/**
* Complete/delete a session (marks as done, broadcasts final state).
* @param {string} sessionKey
* @returns {Promise<void>}
*/
PluginClient.prototype.deleteSession = function (sessionKey) {
var encodedKey = encodeURIComponent(sessionKey);
return this._request('DELETE', '/api/v1/sessions/' + encodedKey, null).then(function () {});
};
/**
* Low-level HTTP request to the plugin.
* @private
*/
PluginClient.prototype._request = function (method, apiPath, body) {
var self = this;
var transport = this.isHttps ? https : http;
var bodyStr = body ? JSON.stringify(body) : null;
return new Promise(function (resolve, reject) {
var reqOpts = {
hostname: self.hostname,
port: self.port,
path: self.basePath + apiPath,
method: method,
agent: self.agent,
headers: {
'Authorization': 'Bearer ' + self.secret,
'Content-Type': 'application/json',
},
timeout: self.timeoutMs,
};
if (bodyStr) {
reqOpts.headers['Content-Length'] = Buffer.byteLength(bodyStr);
}
var req = transport.request(reqOpts, function (res) {
var data = '';
res.on('data', function (chunk) { data += chunk; });
res.on('end', function () {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
resolve(data ? JSON.parse(data) : {});
} catch (_e) {
resolve({});
}
} else {
var msg = 'HTTP ' + res.statusCode;
try { msg = JSON.parse(data).error || msg; } catch (_e) { /* ignore parse errors */ }
var err = new Error(msg);
err.statusCode = res.statusCode;
reject(err);
}
});
});
req.on('timeout', function () {
req.destroy();
reject(new Error('Plugin request timed out'));
});
req.on('error', reject);
if (bodyStr) req.write(bodyStr);
req.end();
});
};
/**
* Destroy the HTTP agent (cleanup).
*/
PluginClient.prototype.destroy = function () {
this.agent.destroy();
};
module.exports = { PluginClient };

574
src/session-monitor.js Normal file
View File

@@ -0,0 +1,574 @@
'use strict';
/**
* session-monitor.js — Polls sessions.json every 2s to detect new/ended sessions.
*
* sessions.json format (per agent):
* {
* "agent:main:mattermost:channel:abc123:thread:xyz": {
* "sessionId": "uuid",
* "spawnedBy": null | "agent:main:...",
* "spawnDepth": 0,
* "label": "proj035-planner",
* "channel": "mattermost"
* }
* }
*
* Emits:
* 'session-added' ({ sessionKey, transcriptFile, spawnedBy, channelId, rootPostId, agentId })
* 'session-removed' (sessionKey)
*/
const fs = require('fs');
const path = require('path');
const { EventEmitter } = require('events');
class SessionMonitor extends EventEmitter {
/**
* @param {object} opts
* @param {string} opts.transcriptDir - Base /home/node/.openclaw/agents directory
* @param {number} [opts.pollMs] - Poll interval in ms (default 2000)
* @param {string|null} [opts.defaultChannel] - Fallback channel ID for non-MM sessions
* @param {string|null} [opts.mmToken] - Mattermost bot token (for DM channel resolution)
* @param {string|null} [opts.mmUrl] - Mattermost base URL
* @param {string|null} [opts.botUserId] - Bot's own Mattermost user ID
* @param {object} [opts.logger] - pino logger
*/
constructor(opts) {
super();
this.transcriptDir = opts.transcriptDir;
this.pollMs = opts.pollMs || 500;
this.defaultChannel = opts.defaultChannel || null;
this.mmToken = opts.mmToken || null;
this.mmUrl = opts.mmUrl || null;
this.botUserId = opts.botUserId || null;
this.logger = opts.logger || null;
// Map<sessionKey, sessionEntry>
this._knownSessions = new Map();
// Set<sessionKey> — sessions that were skipped as stale; re-check on next poll
this._staleSessions = new Set();
// Map<sessionKey, expiresAt> — sessions that completed idle; suppressed from re-detection
// until the transcript file stops being written to (checked on each poll).
this._completedSessions = new Map();
// Cache: "user:XXXX" -> channelId (resolved DM channels)
this._dmChannelCache = new Map();
this._pollTimer = null;
this._running = false;
}
start() {
if (this._running) return;
this._running = true;
// Initial scan
this._poll();
this._pollTimer = setInterval(() => {
this._poll();
}, this.pollMs);
if (this.logger) this.logger.info({ pollMs: this.pollMs }, 'SessionMonitor started');
}
stop() {
this._running = false;
if (this._pollTimer) {
clearInterval(this._pollTimer);
this._pollTimer = null;
}
if (this.logger) this.logger.info('SessionMonitor stopped');
}
/**
* Remove a session from known sessions so it can be re-detected on next poll.
* Called when the watcher marks a session as idle/done.
*
* The session is placed in a cooldown set (_completedSessions). It will only be
* re-emitted as 'session-added' once the transcript file goes stale (>5 min no
* writes), preventing the complete→reactivate loop that occurs when the gateway
* keeps appending events to a session file after the agent finishes its turn.
*
* @param {string} sessionKey
*/
forgetSession(sessionKey) {
this._knownSessions.delete(sessionKey);
// Mark as completed — suppress re-detection while transcript is still active.
// The stale check in _handleNewSession will naturally unblock re-detection
// once the file stops being modified (>5 min gap).
this._completedSessions.set(sessionKey, Date.now());
}
/**
* Explicitly clear a session from the completed cooldown, allowing immediate re-detection.
* Use this when a new gateway session replaces an old one with the same key.
* @param {string} sessionKey
*/
clearCompleted(sessionKey) {
this._completedSessions.delete(sessionKey);
// Also remove from _knownSessions so the next poll sees it as a new session
// and fires _onSessionAdded (which creates the new status box).
// Without this, isKnown=true suppresses _onSessionAdded even after clearCompleted.
this._knownSessions.delete(sessionKey);
}
/**
* Trigger an immediate poll without waiting for the next interval tick.
* Used by ghost-watch reactivation to avoid up to pollMs latency.
*/
pollNow() {
this._poll();
}
/**
* Find a session key by its transcript file path.
* Used when a lock file fires for a session the watcher doesn't have mapped yet.
* @param {string} jsonlPath - Absolute path to the .jsonl file
* @returns {string|null}
*/
findSessionByFile(jsonlPath) {
for (const [sessionKey, entry] of this._knownSessions) {
if (entry.sessionFile === jsonlPath) return sessionKey;
}
// Also check completed sessions
// Re-read sessions.json to find it
for (const agentId of this._getAgentIds()) {
const sessions = this._readSessionsJson(agentId);
for (const [sessionKey, entry] of Object.entries(sessions)) {
if (entry.sessionFile === jsonlPath) return sessionKey;
}
}
return null;
}
/**
* Get all agent IDs under transcriptDir.
* @private
*/
_getAgentIds() {
try {
return fs.readdirSync(this.transcriptDir).filter(f => {
try { return fs.statSync(path.join(this.transcriptDir, f)).isDirectory(); } catch(_) { return false; }
});
} catch(_) { return []; }
}
/**
* Get all agent directories under transcriptDir.
* @private
* @returns {string[]} Agent IDs
*/
_getAgentDirs() {
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
return fs.readdirSync(this.transcriptDir).filter((name) => {
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
return fs.statSync(path.join(this.transcriptDir, name)).isDirectory();
} catch (_e) {
return false;
}
});
} catch (_e) {
return [];
}
}
/**
* Read sessions.json for a given agent.
* @private
* @param {string} agentId
* @returns {object} Sessions map
*/
_readSessionsJson(agentId) {
const sessionsPath = path.join(this.transcriptDir, agentId, 'sessions', 'sessions.json');
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
const raw = fs.readFileSync(sessionsPath, 'utf8');
return JSON.parse(raw);
} catch (_e) {
return {};
}
}
/**
* Resolve the transcript file path for a session.
* @private
* @param {string} agentId
* @param {string} sessionId - UUID
* @returns {string}
*/
_transcriptPath(agentId, sessionId) {
const sessionsDir = path.join(this.transcriptDir, agentId, 'sessions');
const directPath = path.join(sessionsDir, `${sessionId}.jsonl`);
// OpenClaw may use timestamp-prefixed filenames: {ISO}_{sessionId}.jsonl
// Check direct path first, then glob for *_{sessionId}.jsonl
if (fs.existsSync(directPath)) {
return directPath;
}
try {
const files = fs.readdirSync(sessionsDir);
const suffix = `${sessionId}.jsonl`;
const match = files.find(
(f) => f.endsWith(suffix) && f !== suffix && !f.endsWith('.deleted'),
);
if (match) {
return path.join(sessionsDir, match);
}
} catch (_e) {
// Directory doesn't exist or unreadable
}
// Fallback to direct path (will fail with ENOENT, which is handled upstream)
return directPath;
}
/**
* Parse channel ID from session key.
* Session key format: "agent:main:mattermost:channel:{channelId}:thread:{threadId}"
* or: "agent:main:mattermost:dm:{userId}"
* @param {string} sessionKey
* @returns {string|null}
*/
static parseChannelId(sessionKey) {
const parts = sessionKey.split(':');
// agent:main:mattermost:channel:CHANNEL_ID:...
const chanIdx = parts.indexOf('channel');
if (chanIdx >= 0 && parts[chanIdx + 1]) {
return parts[chanIdx + 1]; // eslint-disable-line security/detect-object-injection
}
// agent:main:mattermost:dm:USER_ID (use as channel)
const dmIdx = parts.indexOf('dm');
if (dmIdx >= 0 && parts[dmIdx + 1]) {
return parts[dmIdx + 1]; // eslint-disable-line security/detect-object-injection
}
// agent:main:mattermost:direct:USER_ID — DM sessions use "direct" prefix
// Channel ID must be resolved via API (returns null here; resolveChannelFromEntry handles it)
return null;
}
/**
* Parse root post ID (thread ID) from session key.
* @param {string} sessionKey
* @returns {string|null}
*/
static parseRootPostId(sessionKey) {
const parts = sessionKey.split(':');
const threadIdx = parts.indexOf('thread');
if (threadIdx >= 0 && parts[threadIdx + 1]) {
return parts[threadIdx + 1]; // eslint-disable-line security/detect-object-injection
}
return null;
}
/**
* Extract agent ID from session key.
* @param {string} sessionKey
* @returns {string}
*/
static parseAgentId(sessionKey) {
const parts = sessionKey.split(':');
if (parts[0] === 'agent' && parts[1]) return parts[1];
return parts[0] || 'unknown';
}
/**
* Determine if a session is a Mattermost session.
* @param {string} sessionKey
* @param {object} [sessionEntry] - Session entry from sessions.json
* @returns {boolean}
*/
static isMattermostSession(sessionKey, sessionEntry) {
if (sessionKey.includes(':mattermost:') || sessionKey.includes(':mm:')) return true;
// Check deliveryContext/channel/lastChannel in session entry
if (sessionEntry) {
if (sessionEntry.channel === 'mattermost' || sessionEntry.lastChannel === 'mattermost') return true;
const dc = sessionEntry.deliveryContext;
if (dc && dc.channel === 'mattermost') return true;
}
return false;
}
/**
* Resolve channel ID from session entry's deliveryContext/lastTo/origin.
* Handles "user:XXXX" format by resolving DM channel via Mattermost API.
* @param {object} entry - Session entry from sessions.json
* @returns {Promise<string|null>} Channel ID
*/
async resolveChannelFromEntry(entry) {
// Try deliveryContext.to first, then lastTo, then origin.to
const to = (entry.deliveryContext && entry.deliveryContext.to) || entry.lastTo || (entry.origin && entry.origin.to);
if (!to) return null;
// If it's a channel:XXXX format, extract directly
if (to.startsWith('channel:')) {
return to.slice(8);
}
// If it's a user:XXXX format, resolve the DM channel
if (to.startsWith('user:')) {
const userId = to.slice(5);
return this._resolveDmChannel(userId);
}
return null;
}
/**
* Resolve DM channel ID between the bot and a user via Mattermost API.
* @private
* @param {string} userId
* @returns {Promise<string|null>}
*/
async _resolveDmChannel(userId) {
const cacheKey = `user:${userId}`;
if (this._dmChannelCache.has(cacheKey)) {
return this._dmChannelCache.get(cacheKey);
}
if (!this.mmToken || !this.mmUrl || !this.botUserId) {
if (this.logger) this.logger.warn({ userId }, 'Cannot resolve DM channel — missing mmToken/mmUrl/botUserId');
return null;
}
try {
const url = new URL('/api/v4/channels/direct', this.mmUrl);
const https = require('https');
const http = require('http');
const transport = url.protocol === 'https:' ? https : http;
const channelId = await new Promise((resolve, reject) => {
const body = JSON.stringify([this.botUserId, userId]);
const req = transport.request({
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname,
method: 'POST',
headers: {
'Authorization': `Bearer ${this.mmToken}`,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
rejectUnauthorized: false,
}, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try {
const parsed = JSON.parse(data);
resolve(parsed.id || null);
} catch (_e) {
resolve(null);
}
});
});
req.on('error', (e) => { reject(e); });
req.write(body);
req.end();
});
if (channelId) {
this._dmChannelCache.set(cacheKey, channelId);
if (this.logger) this.logger.info({ userId, channelId }, 'Resolved DM channel');
}
return channelId;
} catch (err) {
if (this.logger) this.logger.warn({ userId, err: err.message }, 'Failed to resolve DM channel');
return null;
}
}
/**
* Poll all agents' sessions.json files for changes.
* @private
*/
_poll() {
if (!this._running) return;
const agentDirs = this._getAgentDirs();
const currentSessions = new Map();
for (const agentId of agentDirs) {
const sessions = this._readSessionsJson(agentId);
for (const [sessionKey, entry] of Object.entries(sessions)) {
const sessionId = entry.sessionId || entry.uuid;
if (!sessionId) continue;
currentSessions.set(sessionKey, {
agentId,
sessionKey,
sessionId,
spawnedBy: entry.spawnedBy || null,
spawnDepth: entry.spawnDepth || 0,
label: entry.label || null,
channel: entry.channel || null,
// Preserve full entry for channel resolution
deliveryContext: entry.deliveryContext || null,
lastTo: entry.lastTo || null,
lastChannel: entry.lastChannel || null,
origin: entry.origin || null,
sessionFile: entry.sessionFile || null,
});
}
}
// Detect new or previously-stale sessions
for (const [sessionKey, entry] of currentSessions) {
const isKnown = this._knownSessions.has(sessionKey);
const isStale = this._staleSessions.has(sessionKey);
if (!isKnown || isStale) {
// Skip sessions in completed cooldown — only allow re-detection when:
// (a) completedSessions cooldown was explicitly cleared (ghost watch / clearCompleted), OR
// (b) sessions.json shows a newer updatedAt than when we marked complete.
if (this._completedSessions.has(sessionKey)) {
const completedAt = this._completedSessions.get(sessionKey);
const sessionUpdatedAt = entry.updatedAt || 0;
if (sessionUpdatedAt > completedAt) {
// New turn started after completion — reactivate
this._completedSessions.delete(sessionKey);
} else {
// No new turn yet — add to knownSessions silently so we don't re-emit
// on every poll cycle, but don't fire _onSessionAdded.
if (!isKnown) this._knownSessions.set(sessionKey, entry);
continue;
}
}
this._knownSessions.set(sessionKey, entry);
this._onSessionAdded(entry);
} else {
// Update the stored entry with latest data (e.g. updatedAt changes)
this._knownSessions.set(sessionKey, entry);
}
}
// Detect removed sessions
for (const [sessionKey] of this._knownSessions) {
if (!currentSessions.has(sessionKey)) {
this._onSessionRemoved(sessionKey);
this._knownSessions.delete(sessionKey);
this._staleSessions.delete(sessionKey);
this._completedSessions.delete(sessionKey);
}
}
// Clean up stale entries for sessions no longer in sessions.json
for (const sessionKey of this._staleSessions) {
if (!currentSessions.has(sessionKey)) {
this._staleSessions.delete(sessionKey);
}
}
// Note: _knownSessions is now maintained incrementally above, not replaced wholesale.
}
/**
* Handle a newly detected session.
* @private
*/
async _onSessionAdded(entry) {
const { agentId, sessionKey, sessionId, spawnedBy, spawnDepth, label } = entry;
// Use sessionFile from sessions.json if available, otherwise resolve
let transcriptFile = entry.sessionFile || null;
if (!transcriptFile || !fs.existsSync(transcriptFile)) {
transcriptFile = this._transcriptPath(agentId, sessionId);
}
// Skip stale sessions — only track if transcript was modified in last 5 minutes
// This prevents creating status boxes for every old session in sessions.json.
// Stale sessions are tracked in _staleSessions and re-checked on every poll
// so they get picked up as soon as the transcript becomes active again.
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
const stat = fs.statSync(transcriptFile);
const ageMs = Date.now() - stat.mtimeMs;
const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
if (ageMs > STALE_THRESHOLD_MS) {
this._staleSessions.add(sessionKey);
if (this.logger) {
this.logger.debug(
{ sessionKey, ageS: Math.floor(ageMs / 1000) },
'Skipping stale session (transcript not recently modified)',
);
}
return;
}
} catch (_e) {
// File doesn't exist — skip silently but track as stale for re-check
this._staleSessions.add(sessionKey);
if (this.logger) {
this.logger.debug(
{ sessionKey, transcriptFile },
'Skipping session (transcript not found)',
);
}
return;
}
// Session is fresh — remove from stale tracking
this._staleSessions.delete(sessionKey);
// Sub-agents always pass through — they inherit parent channel via watcher-manager
const isSubAgent = !!spawnedBy;
// Resolve channel ID — try session key first, then deliveryContext/lastTo
let channelId = SessionMonitor.parseChannelId(sessionKey);
// If session key doesn't contain channel, resolve from session entry metadata
if (!channelId) {
channelId = await this.resolveChannelFromEntry(entry);
}
// Fall back to default channel for non-MM sessions
if (!channelId && !isSubAgent && !SessionMonitor.isMattermostSession(sessionKey, entry)) {
channelId = this.defaultChannel;
if (!channelId) {
if (this.logger) {
this.logger.debug({ sessionKey }, 'Skipping non-MM session (no channel, no default)');
}
return;
}
}
const rootPostId = SessionMonitor.parseRootPostId(sessionKey);
const parsedAgentId = SessionMonitor.parseAgentId(sessionKey);
if (this.logger) {
this.logger.info({ sessionKey, agentId, channelId, spawnedBy }, 'Session detected');
}
this.emit('session-added', {
sessionKey,
transcriptFile,
spawnedBy,
spawnDepth,
channelId,
rootPostId,
agentId: label || parsedAgentId,
});
}
/**
* Handle a removed session.
* @private
*/
_onSessionRemoved(sessionKey) {
if (this.logger) {
this.logger.info({ sessionKey }, 'Session ended');
}
this.emit('session-removed', sessionKey);
}
/**
* Get list of currently known sessions.
* @returns {Map}
*/
getKnownSessions() {
return new Map(this._knownSessions);
}
}
module.exports = { SessionMonitor };

345
src/status-box.js Normal file
View File

@@ -0,0 +1,345 @@
'use strict';
/**
* status-box.js — Mattermost post manager.
*
* Features:
* - Shared http.Agent (keepAlive, maxSockets)
* - createPost / updatePost with circuit breaker
* - Throttle: leading-edge fires immediately, trailing flush after THROTTLE_MS
* - Message size guard (truncate to MAX_MESSAGE_CHARS)
* - Retry with exponential backoff on 429/5xx (up to MAX_RETRIES)
* - Structured logs for every API call
*/
const https = require('https');
const http = require('http');
const { EventEmitter } = require('events');
const { CircuitBreaker } = require('./circuit-breaker');
const DEFAULT_THROTTLE_MS = 500;
const DEFAULT_MAX_CHARS = 15000;
const DEFAULT_MAX_RETRIES = 3;
const DEFAULT_MAX_SOCKETS = 4;
class StatusBox extends EventEmitter {
/**
* @param {object} opts
* @param {string} opts.baseUrl - Mattermost base URL
* @param {string} opts.token - Bot token
* @param {object} [opts.logger] - pino logger
* @param {number} [opts.throttleMs]
* @param {number} [opts.maxMessageChars]
* @param {number} [opts.maxRetries]
* @param {number} [opts.maxSockets]
* @param {CircuitBreaker} [opts.circuitBreaker]
*/
constructor(opts) {
super();
this.baseUrl = opts.baseUrl;
this.token = opts.token;
this.logger = opts.logger || null;
this.throttleMs = opts.throttleMs || DEFAULT_THROTTLE_MS;
this.maxMessageChars = opts.maxMessageChars || DEFAULT_MAX_CHARS;
this.maxRetries = opts.maxRetries || DEFAULT_MAX_RETRIES;
const parsedUrl = new URL(this.baseUrl);
this.hostname = parsedUrl.hostname;
this.port = parsedUrl.port
? parseInt(parsedUrl.port, 10)
: parsedUrl.protocol === 'https:'
? 443
: 80;
this.isHttps = parsedUrl.protocol === 'https:';
const maxSockets = opts.maxSockets || DEFAULT_MAX_SOCKETS;
this.agent = new (this.isHttps ? https : http).Agent({
keepAlive: true,
maxSockets,
});
this.circuitBreaker =
opts.circuitBreaker ||
new CircuitBreaker({
threshold: 5,
cooldownMs: 30000,
logger: this.logger,
});
// Metrics
this.metrics = {
updatesSent: 0,
updatesFailed: 0,
queueDepth: 0,
};
// Throttle state per postId
// Map<postId, { pending: string|null, timer: NodeJS.Timeout|null, lastFiredAt: number }>
this._throttleState = new Map();
}
/**
* Create a new Mattermost post.
* @param {string} channelId
* @param {string} text
* @param {string} [rootId] - Thread root post ID
* @returns {Promise<string>} Post ID
*/
async createPost(channelId, text, rootId) {
const body = { channel_id: channelId, message: this._truncate(text) };
if (rootId) body.root_id = rootId;
const post = await this._apiCall('POST', '/api/v4/posts', body);
if (this.logger) this.logger.debug({ postId: post.id, channelId }, 'Created status post');
this.metrics.updatesSent++;
return post.id;
}
/**
* Delete a Mattermost post.
* @param {string} postId
* @returns {Promise<void>}
*/
async deletePost(postId) {
try {
await this._apiCall('DELETE', `/api/v4/posts/${postId}`);
if (this.logger) this.logger.debug({ postId }, 'Deleted status post');
} catch (err) {
if (this.logger) this.logger.warn({ postId, err: err.message }, 'Failed to delete status post');
throw err;
}
}
/**
* Update a Mattermost post (throttled).
* Leading edge fires immediately; subsequent calls within throttleMs are batched.
* Guaranteed trailing flush when activity stops.
*
* @param {string} postId
* @param {string} text
* @returns {Promise<void>}
*/
updatePost(postId, text) {
return new Promise((resolve, reject) => {
let state = this._throttleState.get(postId);
if (!state) {
state = { pending: null, timer: null, lastFiredAt: 0, resolvers: [] };
this._throttleState.set(postId, state);
}
state.resolvers.push({ resolve, reject });
state.pending = text;
this.metrics.queueDepth = this._throttleState.size;
const now = Date.now();
const elapsed = now - state.lastFiredAt;
if (elapsed >= this.throttleMs && !state.timer) {
// Leading edge: fire immediately
this._flushUpdate(postId);
} else {
// Trailing flush: schedule if not already scheduled
if (!state.timer) {
state.timer = setTimeout(() => {
this._flushUpdate(postId);
}, this.throttleMs - elapsed);
}
// If timer already scheduled, pending text was updated above — it will flush latest text
}
});
}
/**
* Flush the pending update for a postId.
* @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) {
const state = this._throttleState.get(postId);
if (!state || state.pending === null) return;
const text = state.pending;
const resolvers = [...state.resolvers];
state.pending = null;
state.resolvers = [];
state.lastFiredAt = Date.now();
if (state.timer) {
clearTimeout(state.timer);
state.timer = null;
}
this.metrics.queueDepth = Math.max(0, this.metrics.queueDepth - 1);
try {
// In-place PUT update
await this._apiCallWithRetry('PUT', `/api/v4/posts/${postId}`, {
id: postId,
message: this._truncate(text),
});
this.metrics.updatesSent++;
resolvers.forEach(({ resolve }) => resolve());
} catch (err) {
this.metrics.updatesFailed++;
resolvers.forEach(({ reject }) => reject(err));
}
}
/**
* Force-flush any pending update for a postId (used on shutdown).
* @param {string} postId
* @returns {Promise<void>}
*/
async forceFlush(postId) {
const state = this._throttleState.get(postId);
if (!state) return;
if (state.timer) {
clearTimeout(state.timer);
state.timer = null;
}
if (state.pending !== null) {
await this._flushUpdate(postId);
}
}
/**
* Force-flush all pending updates.
* @returns {Promise<void>}
*/
async flushAll() {
const postIds = [...this._throttleState.keys()];
await Promise.allSettled(postIds.map((id) => this.forceFlush(id)));
}
/**
* Truncate text to maxMessageChars.
* @private
*/
_truncate(text) {
if (text.length <= this.maxMessageChars) return text;
const suffix = '\n...(truncated)';
return text.slice(0, this.maxMessageChars - suffix.length) + suffix;
}
/**
* Make an API call through the circuit breaker with retries.
* @private
*/
async _apiCallWithRetry(method, path, body) {
return this.circuitBreaker.execute(() => this._retryApiCall(method, path, body));
}
/**
* Make an API call directly (no circuit breaker, for createPost).
* @private
*/
async _apiCall(method, apiPath, body) {
return this.circuitBreaker.execute(() => this._retryApiCall(method, apiPath, body));
}
/**
* Retry logic for API calls.
* @private
*/
async _retryApiCall(method, apiPath, body, attempt = 0) {
try {
return await this._httpRequest(method, apiPath, body);
} catch (err) {
const isRetryable = err.statusCode === 429 || (err.statusCode >= 500 && err.statusCode < 600);
if (isRetryable && attempt < this.maxRetries) {
const delayMs = Math.min(1000 * Math.pow(2, attempt), 10000);
if (this.logger) {
this.logger.warn(
{ attempt, delayMs, statusCode: err.statusCode },
'API call failed, retrying',
);
}
await sleep(delayMs);
return this._retryApiCall(method, apiPath, body, attempt + 1);
}
throw err;
}
}
/**
* Low-level HTTP request.
* @private
*/
_httpRequest(method, apiPath, body) {
const transport = this.isHttps ? https : http;
const bodyStr = body ? JSON.stringify(body) : null;
return new Promise((resolve, reject) => {
const reqOpts = {
hostname: this.hostname,
port: this.port,
path: apiPath,
method,
agent: this.agent,
headers: {
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json',
},
};
if (bodyStr) {
reqOpts.headers['Content-Length'] = Buffer.byteLength(bodyStr);
}
const req = transport.request(reqOpts, (res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
resolve(data ? JSON.parse(data) : {});
} catch (_e) {
resolve({});
}
} else {
let msg = `HTTP ${res.statusCode}`;
try {
msg = JSON.parse(data).message || msg;
} catch (_e) {
/* use default */
}
const err = new Error(msg);
err.statusCode = res.statusCode;
reject(err);
}
});
});
req.setTimeout(30000, () => {
req.destroy(new Error('HTTP request timed out after 30s'));
});
req.on('error', reject);
if (bodyStr) req.write(bodyStr);
req.end();
});
}
getMetrics() {
return {
...this.metrics,
circuit: this.circuitBreaker.getMetrics(),
};
}
destroy() {
this.agent.destroy();
// Clear all throttle timers
for (const [, state] of this._throttleState) {
if (state.timer) clearTimeout(state.timer);
}
this._throttleState.clear();
}
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
module.exports = { StatusBox };

173
src/status-formatter.js Normal file
View File

@@ -0,0 +1,173 @@
'use strict';
/**
* status-formatter.js — Pure function: SessionState -> formatted Mattermost text.
*
* Output format:
* [ACTIVE] main | 38s
* Reading live-status source code...
* exec: ls /agents/sessions [OK]
* Analyzing agent configurations...
* Sub-agent: proj035-planner
* Reading protocol...
* [DONE] 28s
* [DONE] 53s | 12.4k tokens
*/
const MAX_STATUS_LINES = parseInt(process.env.MAX_STATUS_LINES, 10) || 20;
const MAX_LINE_CHARS = 120;
/**
* Format a SessionState into a Mattermost text string.
*
* @param {object} sessionState
* @param {string} sessionState.sessionKey
* @param {string} sessionState.status - 'active' | 'done' | 'error' | 'interrupted'
* @param {number} sessionState.startTime - ms since epoch
* @param {Array<string>} sessionState.lines - Status lines (most recent activity)
* @param {Array<object>} [sessionState.children] - Child session states
* @param {number} [sessionState.tokenCount] - Token count if available
* @param {string} [sessionState.agentId] - Agent ID (e.g. "main")
* @param {number} [sessionState.depth] - Nesting depth (0 = top-level)
* @returns {string}
*/
function format(sessionState, opts = {}) {
const maxLines = opts.maxLines || MAX_STATUS_LINES;
const depth = sessionState.depth || 0;
const indent = ' '.repeat(depth);
const lines = [];
// Header line
const elapsed = formatElapsed(Date.now() - sessionState.startTime);
const agentId = sessionState.agentId || extractAgentId(sessionState.sessionKey);
const statusPrefix = statusIcon(sessionState.status);
lines.push(`${indent}**${statusPrefix}** \`${agentId}\` | ${elapsed}`);
// Status lines (trimmed to maxLines, most recent)
const statusLines = (sessionState.lines || []).slice(-maxLines);
for (const line of statusLines) {
lines.push(`${indent}${formatStatusLine(truncateLine(line))}`);
}
// Child sessions (sub-agents)
if (sessionState.children && sessionState.children.length > 0) {
for (const child of sessionState.children) {
const childLines = format(child, { maxLines: Math.floor(maxLines / 2), ...opts }).split('\n');
lines.push(...childLines);
}
}
// Footer line (only for done/error/interrupted)
if (sessionState.status !== 'active') {
const tokenStr = sessionState.tokenCount
? ` | ${formatTokens(sessionState.tokenCount)} tokens`
: '';
lines.push(`${indent}**[${sessionState.status.toUpperCase()}]** ${elapsed}${tokenStr}`);
}
// Wrap in blockquote at top level — visually distinct (left border),
// never collapses like code blocks do, supports inline markdown
var body = lines.join('\n');
if (depth === 0) {
body = body
.split('\n')
.map(function (l) {
return '> ' + l;
})
.join('\n');
// Append invisible session marker for restart recovery (search by marker)
body += '\n<!-- sw:' + sessionState.sessionKey + ' -->';
}
return body;
}
/**
* Format elapsed milliseconds as human-readable string.
* @param {number} ms
* @returns {string}
*/
function formatElapsed(ms) {
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 as compact string (e.g. 12400 -> "12.4k").
* @param {number} count
* @returns {string}
*/
function formatTokens(count) {
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(1)}k`;
return String(count);
}
/**
* Get status icon prefix.
* @param {string} status
* @returns {string}
*/
function statusIcon(status) {
switch (status) {
case 'active':
return '[ACTIVE]';
case 'done':
return '[DONE]';
case 'error':
return '[ERROR]';
case 'interrupted':
return '[INTERRUPTED]';
default:
return '[UNKNOWN]';
}
}
/**
* Format a status line with inline markdown.
* Tool calls get inline code formatting; thinking text stays plain.
* @param {string} line
* @returns {string}
*/
function formatStatusLine(line) {
// Tool call lines: "toolName: arguments [OK]" or "toolName: label"
var match = line.match(/^(\S+?): (.+?)( \[OK\]| \[ERR\])?$/);
if (match) {
var marker = match[3] || '';
return '`' + match[1] + ':` ' + match[2] + marker;
}
// Thinking text — use a unicode marker to distinguish from tool calls
// Avoid markdown italic (*) since it breaks with special characters
return '\u2502 ' + line;
}
/**
* Truncate a line to MAX_LINE_CHARS.
* @param {string} line
* @returns {string}
*/
function truncateLine(line) {
if (line.length <= MAX_LINE_CHARS) return line;
return line.slice(0, MAX_LINE_CHARS - 3) + '...';
}
/**
* Extract agent ID from session key.
* Session key format: "agent:main:mattermost:channel:abc123:thread:xyz"
* @param {string} sessionKey
* @returns {string}
*/
function extractAgentId(sessionKey) {
if (!sessionKey) return 'unknown';
const parts = sessionKey.split(':');
// "agent:main:..." -> "main"
if (parts[0] === 'agent' && parts[1]) return parts[1];
return sessionKey.split(':')[0] || 'unknown';
}
module.exports = { format, formatElapsed, formatTokens, statusIcon, truncateLine, extractAgentId };

692
src/status-watcher.js Normal file
View File

@@ -0,0 +1,692 @@
'use strict';
/**
* status-watcher.js — Core JSONL watcher.
*
* - fs.watch on TRANSCRIPT_DIR (recursive)
* - On file change: read new bytes, parse JSONL, update SessionState
* - Map parsed events to status lines
* - Detect file truncation (compaction) -> reset offset
* - Debounce updates via status-box.js throttle
* - Idle detection: pendingToolCalls==0 AND no new lines for IDLE_TIMEOUT_S
* - Emits: 'session-update' (sessionKey, sessionState)
* 'session-idle' (sessionKey)
*/
const fs = require('fs');
const path = require('path');
const { EventEmitter } = require('events');
const { resolve: resolveLabel } = require('./tool-labels');
const MAX_LINES_BUFFER = 50; // Cap state.lines to prevent memory leaks on long sessions
class StatusWatcher extends EventEmitter {
/**
* @param {object} opts
* @param {string} opts.transcriptDir - Base transcript directory
* @param {number} [opts.idleTimeoutS] - Idle timeout in seconds
* @param {object} [opts.logger] - pino logger
*/
constructor(opts) {
super();
this.transcriptDir = opts.transcriptDir;
this.idleTimeoutS = opts.idleTimeoutS || 60;
this.logger = opts.logger || null;
// Map<sessionKey, SessionState>
this.sessions = new Map();
// Map<filePath, sessionKey>
this.fileToSession = new Map();
// fs.Watcher instance
this._watcher = null;
this._running = false;
}
/**
* Register a session to watch.
* @param {string} sessionKey
* @param {string} transcriptFile - Absolute path to {uuid}.jsonl
* @param {object} [initialState] - Pre-populated state (from offset recovery)
*/
addSession(sessionKey, transcriptFile, initialState = {}) {
if (this.sessions.has(sessionKey)) return;
// For new sessions (no saved offset), start from current file position
// so we only show NEW content going forward — not the entire backlog.
// Pass lastOffset: 0 explicitly to read from the beginning.
var startOffset;
if (initialState.lastOffset !== undefined) {
startOffset = initialState.lastOffset;
} else {
try {
var stat = fs.statSync(transcriptFile);
startOffset = stat.size;
} catch (_e) {
startOffset = 0;
}
}
const state = {
sessionKey,
transcriptFile,
status: 'active',
startTime: initialState.startTime || Date.now(),
lines: initialState.lines || [],
pendingToolCalls: 0,
lastOffset: startOffset,
lastActivityAt: Date.now(),
agentId: initialState.agentId || extractAgentId(sessionKey),
depth: initialState.depth || 0,
tokenCount: 0,
children: [],
idleTimer: null,
_lineBuffer: '',
_lockActive: false,
_lockExists: undefined,
_lockPollTimer: null,
};
this.sessions.set(sessionKey, state);
this.fileToSession.set(transcriptFile, sessionKey);
if (this.logger) {
this.logger.debug({ sessionKey, transcriptFile, startOffset }, 'Session added to watcher');
}
// Only read if we have an explicit offset (recovery or explicit 0) — new sessions start streaming from current position
if (initialState.lastOffset !== undefined) {
this._readFile(sessionKey, state);
}
// Start file polling as fallback (fs.watch may not work on bind mounts in Docker)
this._startFilePoll(sessionKey, state);
// Start lock file poll fallback (inotify misses on Docker bind mounts)
this._startLockPoll(sessionKey, state);
}
/**
* Start polling a transcript file for changes (fallback for fs.watch).
* @private
*/
_startFilePoll(sessionKey, state) {
var self = this;
var pollInterval = 500; // 500ms poll
var pollCount = 0;
state._filePollTimer = setInterval(function () {
try {
var stat = fs.statSync(state.transcriptFile);
pollCount++;
if (stat.size > state.lastOffset) {
if (self.logger) {
self.logger.info(
{ sessionKey, fileSize: stat.size, lastOffset: state.lastOffset, delta: stat.size - state.lastOffset, pollCount },
'File poll: new data detected — reading',
);
}
self._readFile(sessionKey, state);
}
} catch (_e) {
// File might not exist yet or was deleted
}
}, pollInterval);
if (self.logger) {
self.logger.info({ sessionKey, pollInterval }, 'File poll timer started');
}
}
/**
* Start polling a lock file for changes (fallback for fs.watch on Docker bind mounts).
* Idempotent with fs.watch — if fs.watch already fired, _lockActive is already updated
* and no duplicate events are emitted.
* @private
*/
_startLockPoll(sessionKey, state) {
var self = this;
var lockFile = state.transcriptFile + '.lock';
state._lockPollTimer = setInterval(function () {
try {
var exists = fs.existsSync(lockFile);
// First poll: just initialize state, don't emit
if (state._lockExists === undefined) {
state._lockExists = exists;
return;
}
var changed = exists !== state._lockExists;
state._lockExists = exists;
if (!changed) return;
if (exists) {
// Lock file appeared — session activated by user message
if (!state._lockActive) {
state._lockActive = true;
if (self.logger) {
self.logger.info({ sessionKey }, 'Lock poll: lock file appeared — emitting session-lock');
}
self.emit('session-lock', sessionKey);
}
} else {
// Lock file disappeared — turn complete
if (state._lockActive) {
state._lockActive = false;
if (self.logger) {
self.logger.info({ sessionKey }, 'Lock poll: lock file removed — emitting session-lock-released');
}
self.emit('session-lock-released', sessionKey);
}
}
} catch (_e) {
// Ignore stat errors (file/dir may not exist yet)
}
}, 1000);
if (self.logger) {
self.logger.info({ sessionKey }, 'Lock poll timer started');
}
}
/**
* Remove a session from watching.
* @param {string} sessionKey
*/
removeSession(sessionKey) {
const state = this.sessions.get(sessionKey);
if (!state) return;
if (state.idleTimer) clearTimeout(state.idleTimer);
if (state._filePollTimer) clearInterval(state._filePollTimer);
if (state._lockPollTimer) clearInterval(state._lockPollTimer);
// Keep fileToSession mapping alive so fs.watch still fires for this file.
// Mark it as a "ghost" — changes trigger 'session-file-changed' so the
// session-monitor can immediately re-detect without waiting for its poll.
this.fileToSession.set(state.transcriptFile, '\x00ghost:' + sessionKey);
this.sessions.delete(sessionKey);
if (this.logger) {
this.logger.debug({ sessionKey }, 'Session removed from watcher (ghost watch active)');
}
}
/**
* Get current session state (for offset persistence).
* @param {string} sessionKey
* @returns {object|null}
*/
getSessionState(sessionKey) {
return this.sessions.get(sessionKey) || null;
}
/**
* Start the file system watcher.
*/
start() {
if (this._running) return;
this._running = true;
try {
this._watcher = fs.watch(this.transcriptDir, { recursive: true }, (eventType, filename) => {
if (!filename) return;
const fullPath = path.resolve(this.transcriptDir, filename);
this._onFileChange(fullPath);
});
this._watcher.on('error', (err) => {
if (this.logger) this.logger.error({ err }, 'fs.watch error');
this.emit('error', err);
});
if (this.logger) {
this.logger.info({ dir: this.transcriptDir }, 'StatusWatcher started (fs.watch)');
}
} catch (err) {
if (this.logger) {
this.logger.error({ err }, 'Failed to start fs.watch — transcriptDir may not exist');
}
this._running = false;
throw err;
}
}
/**
* Stop the file system watcher.
*/
stop() {
this._running = false;
if (this._watcher) {
this._watcher.close();
this._watcher = null;
}
// Clear all timers
for (const [, state] of this.sessions) {
if (state.idleTimer) {
clearTimeout(state.idleTimer);
state.idleTimer = null;
}
if (state._filePollTimer) {
clearInterval(state._filePollTimer);
state._filePollTimer = null;
}
if (state._lockPollTimer) {
clearInterval(state._lockPollTimer);
state._lockPollTimer = null;
}
}
if (this.logger) this.logger.info('StatusWatcher stopped');
}
/**
* Handle a file change event.
* @private
*/
_onFileChange(fullPath) {
// Lock file appeared/changed: gateway started processing a new turn.
// This fires BEFORE any JSONL content is written — earliest possible signal.
// Emit 'session-lock' so watcher-manager can trigger immediate reactivation
// without waiting for the first JSONL write.
if (fullPath.endsWith('.jsonl.lock')) {
const jsonlPath = fullPath.slice(0, -5); // strip '.lock'
const lockExists = (() => { try { fs.statSync(fullPath); return true; } catch(_) { return false; } })();
// Resolve session key from known sessions or ghost watch
let sessionKey = this.fileToSession.get(jsonlPath);
if (sessionKey && sessionKey.startsWith('\x00ghost:')) sessionKey = sessionKey.slice(7);
if (lockExists) {
// Lock file CREATED — gateway started processing user message.
// Earliest possible signal: fires before any JSONL write.
if (sessionKey) {
const sessionState = this.sessions.get(sessionKey);
// Sync _lockActive so poll fallback stays idempotent
if (sessionState && !sessionState._lockActive) {
sessionState._lockActive = true;
sessionState._lockExists = true;
}
if (this.logger) this.logger.info({ sessionKey }, 'Lock file created — session active, triggering early reactivation');
this.emit('session-lock', sessionKey);
} else {
this.emit('session-lock-path', jsonlPath);
}
} else {
// Lock file DELETED — gateway finished the turn and sent final reply.
// Immediate idle signal: no need to wait for cache-ttl or 60s timeout.
if (sessionKey) {
const sessionState = this.sessions.get(sessionKey);
// Sync _lockActive so poll fallback stays idempotent
if (sessionState && sessionState._lockActive) {
sessionState._lockActive = false;
sessionState._lockExists = false;
}
if (this.logger) this.logger.info({ sessionKey }, 'Lock file deleted — turn complete, marking session done immediately');
this.emit('session-lock-released', sessionKey);
} else {
this.emit('session-lock-released-path', jsonlPath);
}
}
return;
}
// Only process .jsonl files
if (!fullPath.endsWith('.jsonl')) return;
const sessionKey = this.fileToSession.get(fullPath);
if (!sessionKey) return;
// Ghost watch: file changed for a completed session — signal immediate re-detection
if (sessionKey.startsWith('\x00ghost:')) {
const originalKey = sessionKey.slice(7);
// Do NOT delete ghost entry here — let caller clean up after pollNow confirms the session
if (this.logger) {
this.logger.info({ sessionKey: originalKey }, 'fs.watch: file change on completed session — triggering reactivation');
}
this.emit('session-reactivate', originalKey, fullPath);
return;
}
const state = this.sessions.get(sessionKey);
if (!state) return;
if (this.logger) {
this.logger.info({ sessionKey, fullPath: path.basename(fullPath) }, 'fs.watch: file change detected');
}
this._readFile(sessionKey, state);
}
/**
* Read new bytes from a transcript file.
* @private
*/
_readFile(sessionKey, state) {
let fd;
try {
fd = fs.openSync(state.transcriptFile, 'r');
const stat = fs.fstatSync(fd);
const fileSize = stat.size;
// Detect file truncation (compaction) or stale offset from a previous session.
// In both cases reset to current file end — we don't want to re-parse old content,
// only new bytes written from this point forward.
if (fileSize < state.lastOffset) {
if (this.logger) {
this.logger.warn(
{ sessionKey, fileSize, lastOffset: state.lastOffset },
'Transcript offset past file end (compaction/stale) — resetting to file end',
);
}
state.lastOffset = fileSize;
state.lines = [];
state.pendingToolCalls = 0;
}
if (fileSize <= state.lastOffset) {
fs.closeSync(fd);
return;
}
// Read new bytes
const bytesToRead = fileSize - state.lastOffset;
const buffer = Buffer.allocUnsafe(bytesToRead);
const bytesRead = fs.readSync(fd, buffer, 0, bytesToRead, state.lastOffset);
fs.closeSync(fd);
state.lastOffset += bytesRead;
// Parse JSONL lines — handle partial lines at chunk boundary
const chunk = buffer.toString('utf8', 0, bytesRead);
const raw = (state._lineBuffer || '') + chunk;
state._lineBuffer = raw.endsWith('\n') ? '' : raw.split('\n').pop();
const lines = raw.split('\n').slice(0, raw.endsWith('\n') ? undefined : -1).filter((l) => l.trim());
for (const line of lines) {
this._parseLine(sessionKey, state, line);
}
// Cap lines buffer to prevent unbounded growth on long sessions
if (state.lines.length > MAX_LINES_BUFFER) {
state.lines = state.lines.slice(-MAX_LINES_BUFFER);
}
// Update activity timestamp
state.lastActivityAt = Date.now();
// Schedule idle check
this._scheduleIdleCheck(sessionKey, state);
// Emit update event
this.emit('session-update', sessionKey, this._sanitizeState(state));
} catch (err) {
if (fd !== undefined) {
try {
fs.closeSync(fd);
} catch (_e) {
/* ignore close error */
}
}
if (this.logger) {
this.logger.error({ sessionKey, err }, 'Error reading transcript file');
}
}
}
/**
* Parse a single JSONL line and update session state.
* @private
*/
_parseLine(sessionKey, state, line) {
let record;
try {
record = JSON.parse(line);
} catch (_e) {
// Skip malformed lines
return;
}
// OpenClaw transcript format: all records have type="message"
// with record.message.role = "assistant" | "toolResult" | "user"
// and record.message.content = array of {type: "text"|"toolCall", ...}
if (record.type === 'message' && record.message) {
const msg = record.message;
const role = msg.role;
if (role === 'assistant') {
// Extract usage if present
if (msg.usage && msg.usage.totalTokens) {
state.tokenCount = msg.usage.totalTokens;
}
// Parse content items
const contentItems = Array.isArray(msg.content) ? msg.content : [];
for (var i = 0; i < contentItems.length; i++) {
var item = contentItems[i];
if (item.type === 'toolCall') {
state.pendingToolCalls++;
var toolName = item.name || 'unknown';
var label = resolveLabel(toolName);
// Show tool name with key arguments
var argStr = '';
if (item.arguments) {
if (item.arguments.command) {
argStr = item.arguments.command.slice(0, 60);
} else if (item.arguments.file_path || item.arguments.path) {
argStr = item.arguments.file_path || item.arguments.path;
} else if (item.arguments.query) {
argStr = item.arguments.query.slice(0, 60);
} else if (item.arguments.url) {
argStr = item.arguments.url.slice(0, 60);
}
}
var statusLine = argStr ? toolName + ': ' + argStr : toolName + ': ' + label;
state.lines.push(statusLine);
} else if (item.type === 'text' && item.text) {
var text = item.text.trim();
if (text) {
var truncated = text.length > 100 ? text.slice(0, 97) + '...' : text;
state.lines.push(truncated);
}
}
}
} else if (role === 'toolResult') {
if (state.pendingToolCalls > 0) state.pendingToolCalls--;
var resultToolName = msg.toolName || 'unknown';
var isError = msg.isError === true;
var marker = isError ? '[ERR]' : '[OK]';
var idx = findLastIndex(state.lines, function (l) {
return l.indexOf(resultToolName + ':') === 0;
});
if (idx >= 0) {
state.lines[idx] = state.lines[idx].replace(/( \[OK\]| \[ERR\])?$/, ' ' + marker);
}
} else if (role === 'user') {
// User messages — could show "User: ..." but skip for now to reduce noise
}
return;
}
// OpenClaw cache-ttl custom record: reliable "turn complete" signal.
// Emitted after every assistant turn. Use it to fast-idle the session
// instead of waiting the full IDLE_TIMEOUT_S.
if (record.type === 'custom' && record.customType === 'openclaw.cache-ttl') {
if (state.pendingToolCalls === 0) {
// Turn is done — fast-idle: fire idle check after a short grace period (3s)
// to allow any trailing writes to flush before marking complete.
if (state.idleTimer) clearTimeout(state.idleTimer);
state.idleTimer = setTimeout(() => {
this._checkIdle(sessionKey);
}, 3000);
}
return;
}
// Legacy format fallback (for compatibility)
var legacyType = record.type;
switch (legacyType) {
case 'tool_call': {
state.pendingToolCalls++;
var tn = record.name || record.tool || 'unknown';
var lb = resolveLabel(tn);
state.lines.push(tn + ': ' + lb);
break;
}
case 'tool_result': {
if (state.pendingToolCalls > 0) state.pendingToolCalls--;
var rtn = record.name || record.tool || 'unknown';
var mk = record.error ? '[ERR]' : '[OK]';
var ri = findLastIndex(state.lines, function (l) {
return l.indexOf(rtn + ':') === 0;
});
if (ri >= 0) {
state.lines[ri] = state.lines[ri].replace(/( \[OK\]| \[ERR\])?$/, ' ' + mk);
}
break;
}
case 'assistant': {
var at = (record.text || record.content || '').trim();
if (at) {
var tr = at.length > 100 ? at.slice(0, 97) + '...' : at;
state.lines.push(tr);
}
break;
}
case 'usage': {
if (record.total_tokens) state.tokenCount = record.total_tokens;
else if (record.input_tokens || record.output_tokens) {
state.tokenCount = (record.input_tokens || 0) + (record.output_tokens || 0);
}
break;
}
case 'session_start': {
state.startTime = record.timestamp ? new Date(record.timestamp).getTime() : state.startTime;
break;
}
default:
break;
}
}
/**
* Schedule an idle check for a session.
* @private
*/
/**
* Immediately mark a session as done without waiting for idle timeout.
* Called when the lock file is deleted — the gateway has sent the final reply.
* @param {string} sessionKey
*/
triggerIdle(sessionKey) {
const state = this.sessions.get(sessionKey);
if (!state) return;
// Cancel any pending timers
if (state.idleTimer) { clearTimeout(state.idleTimer); state.idleTimer = null; }
if (state._filePollTimer) { clearInterval(state._filePollTimer); state._filePollTimer = null; }
if (this.logger) this.logger.info({ sessionKey }, 'triggerIdle: lock released — marking session done immediately');
state.status = 'done';
this.emit('session-idle', sessionKey, this._sanitizeState(state));
}
_scheduleIdleCheck(sessionKey, state) {
if (state.idleTimer) {
clearTimeout(state.idleTimer);
}
state.idleTimer = setTimeout(() => {
this._checkIdle(sessionKey);
}, this.idleTimeoutS * 1000);
}
/**
* Check if a session is idle.
* @private
*/
_checkIdle(sessionKey) {
const state = this.sessions.get(sessionKey);
if (!state) return;
const elapsed = Date.now() - state.lastActivityAt;
const idleMs = this.idleTimeoutS * 1000;
// Safeguard: if pendingToolCalls is stuck > 0 for more than 30s, clamp to 0
if (state.pendingToolCalls > 0 && elapsed > 30000) {
if (this.logger) {
this.logger.warn(
{ sessionKey, pendingToolCalls: state.pendingToolCalls, elapsedS: Math.floor(elapsed / 1000) },
'_checkIdle: pendingToolCalls stuck > 30s — clamping to 0 to unblock idle detection',
);
}
state.pendingToolCalls = 0;
}
if (elapsed >= idleMs && state.pendingToolCalls === 0) {
if (this.logger) {
this.logger.info({ sessionKey, elapsedS: Math.floor(elapsed / 1000) }, 'Session idle');
}
state.status = 'done';
state.idleTimer = null;
this.emit('session-idle', sessionKey, this._sanitizeState(state));
} else {
// Reschedule
this._scheduleIdleCheck(sessionKey, state);
}
}
/**
* Return a safe copy of session state (without circular refs, timers).
* @private
*/
_sanitizeState(state) {
return {
sessionKey: state.sessionKey,
transcriptFile: state.transcriptFile,
status: state.status,
startTime: state.startTime,
lines: [...state.lines],
pendingToolCalls: state.pendingToolCalls,
lastOffset: state.lastOffset,
lastActivityAt: state.lastActivityAt,
agentId: state.agentId,
depth: state.depth,
tokenCount: state.tokenCount,
children: state.children,
};
}
}
/**
* Extract agent ID from session key.
* @param {string} sessionKey
* @returns {string}
*/
function extractAgentId(sessionKey) {
if (!sessionKey) return 'unknown';
const parts = sessionKey.split(':');
if (parts[0] === 'agent' && parts[1]) return parts[1];
return parts[0] || 'unknown';
}
/**
* Find the last index in an array satisfying a predicate.
* @param {Array} arr
* @param {Function} predicate
* @returns {number}
*/
function findLastIndex(arr, predicate) {
for (let i = arr.length - 1; i >= 0; i--) {
if (predicate(arr[i])) return i; // eslint-disable-line security/detect-object-injection
}
return -1;
}
module.exports = { StatusWatcher };

109
src/tool-labels.js Normal file
View File

@@ -0,0 +1,109 @@
'use strict';
/**
* tool-labels.js — Pattern-matching tool name -> label resolver.
*
* Resolution order:
* 1. Exact match (e.g. "exec" -> "Running command...")
* 2. Prefix match (e.g. "camofox_*" -> "Using browser...")
* 3. Regex match (e.g. /^claude_/ -> "Running Claude Code...")
* 4. Default label ("Working...")
*/
const path = require('path');
const fs = require('fs');
let _labels = null;
let _externalFile = null;
/**
* Load tool labels from JSON file(s).
* Merges external override on top of built-in defaults.
* @param {string|null} externalFile - Path to external JSON override
*/
function loadLabels(externalFile = null) {
_externalFile = externalFile;
// Load built-in defaults
const builtinPath = path.join(__dirname, 'tool-labels.json');
let builtin = {};
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
builtin = JSON.parse(fs.readFileSync(builtinPath, 'utf8'));
} catch (_e) {
/* use empty defaults if file missing */
}
let external = {};
if (externalFile) {
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
external = JSON.parse(fs.readFileSync(externalFile, 'utf8'));
} catch (_e) {
/* external file missing or invalid — use built-in only */
}
}
// Merge: external overrides built-in
_labels = {
exact: Object.assign({}, builtin.exact || {}, external.exact || {}),
prefix: Object.assign({}, builtin.prefix || {}, external.prefix || {}),
regex: [...(builtin.regex || []), ...(external.regex || [])],
default: external.default !== undefined ? external.default : builtin.default || 'Working...',
};
return _labels;
}
/**
* Resolve a tool name to a human-readable label.
* @param {string} toolName
* @returns {string}
*/
function resolve(toolName) {
if (!_labels) loadLabels(_externalFile);
const labels = _labels;
// 1. Exact match
if (Object.prototype.hasOwnProperty.call(labels.exact, toolName)) {
return labels.exact[toolName]; // eslint-disable-line security/detect-object-injection
}
// 2. Prefix match
for (const [prefix, label] of Object.entries(labels.prefix)) {
if (toolName.startsWith(prefix)) {
return label;
}
}
// 3. Regex match (patterns stored as strings like "/^claude_/i")
for (const entry of labels.regex || []) {
const pattern = typeof entry === 'string' ? entry : entry.pattern;
const label = typeof entry === 'string' ? labels.default : entry.label;
if (pattern) {
try {
const match = pattern.match(/^\/(.+)\/([gimuy]*)$/);
if (match) {
const re = new RegExp(match[1], match[2]); // eslint-disable-line security/detect-non-literal-regexp
if (re.test(toolName)) return label;
}
} catch (_e) {
/* invalid regex — skip */
}
}
}
// 4. Default
return labels.default;
}
/**
* Reset labels (for tests).
*/
function resetLabels() {
_labels = null;
_externalFile = null;
}
module.exports = { loadLabels, resolve, resetLabels };

41
src/tool-labels.json Normal file
View File

@@ -0,0 +1,41 @@
{
"_comment": "Tool name to human-readable label mapping. Supports exact match, prefix match (end with *), and regex (start with /).",
"exact": {
"Read": "Reading file...",
"Write": "Writing file...",
"Edit": "Editing file...",
"exec": "Running command...",
"process": "Managing process...",
"web_search": "Searching the web...",
"web_fetch": "Fetching URL...",
"browser": "Controlling browser...",
"canvas": "Drawing canvas...",
"nodes": "Querying nodes...",
"message": "Sending message...",
"tts": "Generating speech...",
"subagents": "Managing sub-agents...",
"image": "Analyzing image...",
"camofox_create_tab": "Opening browser tab...",
"camofox_close_tab": "Closing browser tab...",
"camofox_navigate": "Navigating browser...",
"camofox_click": "Clicking element...",
"camofox_type": "Typing in browser...",
"camofox_scroll": "Scrolling page...",
"camofox_screenshot": "Taking screenshot...",
"camofox_snapshot": "Capturing page snapshot...",
"camofox_list_tabs": "Listing browser tabs...",
"camofox_import_cookies": "Importing cookies...",
"claude_code_start": "Starting Claude Code task...",
"claude_code_status": "Checking Claude Code status...",
"claude_code_output": "Reading Claude Code output...",
"claude_code_cancel": "Cancelling Claude Code task...",
"claude_code_cleanup": "Cleaning up Claude Code sessions...",
"claude_code_sessions": "Listing Claude Code sessions..."
},
"prefix": {
"camofox_": "Using browser...",
"claude_code_": "Running Claude Code...",
"nodes_": "Querying nodes..."
},
"default": "Working..."
}

840
src/watcher-manager.js Normal file
View File

@@ -0,0 +1,840 @@
#!/usr/bin/env node
'use strict';
/* eslint-disable no-console */
/**
* watcher-manager.js — Top-level orchestrator for the Live Status v4 daemon.
*
* CLI: node watcher-manager.js start|stop|status
*
* Architecture:
* - SessionMonitor polls sessions.json every 2s for new/ended sessions
* - StatusWatcher watches transcript files via fs.watch (inotify)
* - StatusBox manages Mattermost posts (throttle, circuit breaker, retry)
* - HealthServer exposes /health endpoint
* - Offset persistence: save/restore last read positions on restart
* - Graceful shutdown: SIGTERM/SIGINT -> mark all boxes interrupted -> exit 0
*/
const fs = require('fs');
const { getConfig } = require('./config');
const { getLogger } = require('./logger');
const { SessionMonitor } = require('./session-monitor');
const { StatusWatcher } = require('./status-watcher');
const { StatusBox } = require('./status-box');
const { PluginClient } = require('./plugin-client');
// status-formatter is used inline via require() in helpers
const { HealthServer } = require('./health');
const { loadLabels } = require('./tool-labels');
// ---- CLI Router ----
const cmd = process.argv[2];
if (cmd === 'start') {
startDaemon().catch((err) => {
console.error('Failed to start:', err.message);
process.exit(1);
});
} else if (cmd === 'stop') {
stopDaemon();
} else if (cmd === 'status') {
daemonStatus();
} else {
console.log('Usage: node watcher-manager.js <start|stop|status>');
process.exit(1);
}
// ---- PID File helpers ----
function writePidFile(pidFile) {
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
const fd = fs.openSync(pidFile, 'wx');
fs.writeSync(fd, String(process.pid));
fs.closeSync(fd);
} catch (err) {
if (err.code === 'EEXIST') {
// Another process won the race — bail out
console.error('PID file already exists — another daemon may be running');
process.exit(1);
}
throw err;
}
}
function readPidFile(pidFile) {
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
return parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
} catch (_e) {
return null;
}
}
function removePidFile(pidFile) {
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
fs.unlinkSync(pidFile);
} catch (_e) {
/* ignore */
}
}
function isProcessRunning(pid) {
try {
process.kill(pid, 0);
return true;
} catch (_e) {
return false;
}
}
// ---- Offset persistence ----
function loadOffsets(offsetFile) {
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
return JSON.parse(fs.readFileSync(offsetFile, 'utf8'));
} catch (_e) {
return {};
}
}
function saveOffsets(offsetFile, sessions) {
const offsets = {};
for (const [key, state] of sessions) {
offsets[key] = {
lastOffset: state.lastOffset || 0,
transcriptFile: state.transcriptFile,
startTime: state.startTime,
};
}
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
fs.writeFileSync(offsetFile, JSON.stringify(offsets, null, 2), 'utf8');
} catch (writeErr) {
// Log disk-full / permission errors so they are visible in daemon logs
// eslint-disable-next-line no-console
console.warn('[saveOffsets] Failed to write offset file:', writeErr.message);
}
}
// ---- Status box marker for post recovery ----
function makeMarker(sessionKey) {
return `<!-- sw:${sessionKey} -->`;
}
// ---- Main daemon ----
async function startDaemon() {
const config = getConfig();
const logger = getLogger();
// Global error handlers — prevent silent daemon death from unhandled rejections
process.on('unhandledRejection', (reason) => {
logger.error({ reason }, 'Unhandled promise rejection — shutting down');
shutdown().finally(() => process.exit(1));
});
process.on('uncaughtException', (err) => {
logger.error({ err }, 'Uncaught exception — shutting down');
shutdown().finally(() => process.exit(1));
});
// Check if already running
const existingPid = readPidFile(config.pidFile);
if (existingPid && isProcessRunning(existingPid)) {
console.error(`Daemon already running (PID ${existingPid})`);
process.exit(1);
}
removePidFile(config.pidFile);
// Write PID file
writePidFile(config.pidFile);
logger.info({ pid: process.pid, pidFile: config.pidFile }, 'Status watcher daemon starting');
// Load tool labels
loadLabels(config.toolLabelsFile);
// Load persisted offsets for restart recovery
const savedOffsets = loadOffsets(config.offsetFile);
logger.info({ count: Object.keys(savedOffsets).length }, 'Loaded persisted session offsets');
// Shared state
// Map<sessionKey, { postId, agentId, channelId, rootPostId, children: Map }>
const activeBoxes = new Map();
// Guard against concurrent session-added events for the same key (e.g. lock + ghost fire simultaneously)
const sessionAddInProgress = new Set();
// Completed sessions: Map<sessionKey, { postId, lastOffset }>
// Tracks sessions that went idle so we can reuse their post on reactivation
// instead of creating duplicate status boxes.
const completedBoxes = new Map();
// Pending sub-agents: spawnedBy key -> [info] (arrived before parent was added)
const pendingSubAgents = new Map();
let globalMetrics = {
activeSessions: 0,
updatesSent: 0,
updatesFailed: 0,
queueDepth: 0,
lastError: null,
circuit: { state: 'unknown' },
};
// Shared StatusBox instance (single http.Agent pool)
const sharedStatusBox = new StatusBox({
baseUrl: config.mm.baseUrl,
token: config.mm.token,
logger: logger.child({ module: 'status-box' }),
throttleMs: config.throttleMs,
maxMessageChars: config.maxMessageChars,
maxRetries: config.maxRetries,
maxSockets: config.mm.maxSockets,
});
// Plugin client (optional — enhances rendering via WebSocket instead of PUT)
var pluginClient = null;
var usePlugin = false;
if (config.plugin && config.plugin.enabled && config.plugin.url && config.plugin.secret) {
pluginClient = new PluginClient({
pluginUrl: config.plugin.url,
secret: config.plugin.secret,
logger: logger.child({ module: 'plugin-client' }),
});
// Initial plugin detection (awaited before monitor starts — see below)
try {
var healthy = await pluginClient.isHealthy();
usePlugin = healthy;
logger.info({ usePlugin, url: config.plugin.url }, healthy
? 'Plugin detected — using WebSocket rendering mode'
: 'Plugin not available — using REST API fallback');
} catch (_detectErr) {
usePlugin = false;
logger.warn('Plugin detection failed — using REST API fallback');
}
// Periodic re-detection
setInterval(function () {
pluginClient.isHealthy().then(function (healthy) {
if (healthy !== usePlugin) {
usePlugin = healthy;
logger.info({ usePlugin }, healthy ? 'Plugin came online' : 'Plugin went offline — fallback to REST API');
}
});
}, config.plugin.detectIntervalMs || 60000);
}
// StatusWatcher
const watcher = new StatusWatcher({
transcriptDir: config.transcriptDir,
idleTimeoutS: config.idleTimeoutS,
logger: logger.child({ module: 'status-watcher' }),
});
// SessionMonitor
const monitor = new SessionMonitor({
transcriptDir: config.transcriptDir,
pollMs: config.sessionPollMs,
defaultChannel: config.defaultChannel,
mmToken: config.mm.token,
mmUrl: config.mm.baseUrl,
botUserId: config.mm.botUserId,
logger: logger.child({ module: 'session-monitor' }),
});
// Health server
const healthServer = new HealthServer({
port: config.healthPort,
logger: logger.child({ module: 'health' }),
getMetrics: () => ({
...globalMetrics,
...sharedStatusBox.getMetrics(),
activeSessions: activeBoxes.size,
pluginEnabled: usePlugin,
}),
});
// ---- Session Added ----
monitor.on('session-added', async (info) => {
const { sessionKey, transcriptFile, spawnedBy, channelId, rootPostId, agentId } = info;
// Guard: prevent duplicate concurrent session-added for same key.
// Happens when lock file event + ghost watch both fire simultaneously,
// both call pollNow(), and both session-added events land before activeBoxes is updated.
if (sessionAddInProgress.has(sessionKey)) {
logger.debug({ sessionKey }, 'session-added already in progress — dedup skip');
return;
}
if (activeBoxes.has(sessionKey)) {
logger.debug({ sessionKey }, 'session-added for already-active session — skip');
return;
}
sessionAddInProgress.add(sessionKey);
// Skip if no channel
if (!channelId) {
logger.debug({ sessionKey }, 'No channel for session — skipping');
return;
}
// Skip heartbeat/internal sessions (agent:main:main, agent:main:cli, etc.)
// These have no real Mattermost conversation context and produce spurious status boxes.
// A heartbeat session key ends with ':main' and has no channel/thread suffix.
if (/^agent:[^:]+:main$/.test(sessionKey) || /^agent:[^:]+:cli$/.test(sessionKey)) {
logger.debug({ sessionKey }, 'Heartbeat/internal session — skipping status box');
return;
}
// Dedup: skip if another active session already owns this channel.
// This prevents duplicate status boxes when a threadless parent session and a
// thread-specific child session both resolve to the same MM channel/DM.
// Thread sessions (containing ':thread:') take priority over bare channel sessions.
const isThreadSession = sessionKey.includes(':thread:');
for (const [existingKey, existingBox] of activeBoxes) {
if (existingBox.channelId === channelId) {
const existingIsThread = existingKey.includes(':thread:');
if (isThreadSession && !existingIsThread) {
// New session is a thread — it takes priority. Remove the parent box.
logger.info({ sessionKey, displaced: existingKey }, 'Thread session displacing bare channel session');
activeBoxes.delete(existingKey);
watcher.removeSession(existingKey);
} else if (!isThreadSession && existingIsThread) {
// Existing session is a thread — skip this bare channel session.
logger.debug({ sessionKey, existingKey }, 'Bare channel session skipped — thread session already owns this channel');
return;
} else {
// Same type — skip the newcomer, first-in wins.
logger.debug({ sessionKey, existingKey }, 'Duplicate channel session skipped');
return;
}
break;
}
}
// Enforce MAX_ACTIVE_SESSIONS
if (activeBoxes.size >= config.maxActiveSessions) {
logger.warn(
{ sessionKey, maxActiveSessions: config.maxActiveSessions },
'Max active sessions reached — dropping session',
);
return;
}
// Sub-agent: skip creating own post (embedded in parent)
if (spawnedBy) {
if (activeBoxes.has(spawnedBy)) {
linkSubAgent(
activeBoxes,
watcher,
spawnedBy,
{ sessionKey, transcriptFile, channelId, agentId, spawnDepth: info.spawnDepth },
logger,
);
} else {
// Parent not yet tracked — queue for later
logger.debug({ sessionKey, spawnedBy }, 'Sub-agent queued (parent not yet tracked)');
if (!pendingSubAgents.has(spawnedBy)) pendingSubAgents.set(spawnedBy, []);
pendingSubAgents
.get(spawnedBy)
.push({ sessionKey, transcriptFile, channelId, agentId, spawnDepth: info.spawnDepth });
}
return;
}
let postId;
// Check if this session was previously completed (reactivation after idle)
const completed = completedBoxes.get(sessionKey);
if (completed) {
// Delete the old buried post and create a fresh one at the current thread position
// so users can see it without scrolling up
try {
await sharedStatusBox.deletePost(completed.postId);
logger.info({ sessionKey, oldPostId: completed.postId }, 'Deleted old buried status box on reactivation');
} catch (err) {
logger.warn({ sessionKey, oldPostId: completed.postId, err: err.message }, 'Failed to delete old status box (may already be deleted)');
}
completedBoxes.delete(sessionKey);
// postId stays null — will create a fresh one below
logger.info({ sessionKey }, 'Reactivating session — creating fresh status box');
}
// Check for existing post (restart recovery — REST mode only).
// In plugin mode we always create a fresh post: the plugin manages its own
// KV store and the old post is already marked done. Reusing it would make
// the status box appear stuck at the old position in the thread.
if (!postId && !(usePlugin && pluginClient)) {
const saved = savedOffsets[sessionKey]; // eslint-disable-line security/detect-object-injection
if (saved) {
// Try to find existing post in channel history
postId = await findExistingPost(sharedStatusBox, channelId, sessionKey, logger);
}
}
// Create new post if none found
if (!postId) {
try {
if (usePlugin && pluginClient) {
// Plugin mode: always create a fresh custom_livestatus post.
// The plugin replaces any existing KV entry for this sessionKey so there
// is no risk of duplicate boxes.
postId = await pluginClient.createSession(sessionKey, channelId, rootPostId, agentId);
logger.info({ sessionKey, postId, channelId, mode: 'plugin' }, 'Created status box via plugin');
} else {
// REST API fallback: create regular post with formatted text
var initialText = buildInitialText(agentId, sessionKey);
postId = await sharedStatusBox.createPost(channelId, initialText, rootPostId);
logger.info({ sessionKey, postId, channelId, mode: 'rest' }, 'Created status box via REST API');
}
} catch (err) {
logger.error({ sessionKey, err }, 'Failed to create status post');
globalMetrics.lastError = err.message;
sessionAddInProgress.delete(sessionKey);
return;
}
}
activeBoxes.set(sessionKey, {
postId,
channelId,
agentId,
rootPostId,
usePlugin: usePlugin && !!pluginClient, // track which mode this session uses
children: new Map(),
});
sessionAddInProgress.delete(sessionKey);
globalMetrics.activeSessions = activeBoxes.size;
// Register in watcher.
// For reactivated sessions (completedBoxes had an entry), always start from
// current file size so we only stream NEW content from this turn forward.
// Using a stale savedOffset from a prior run would skip past the current turn
// (file grew since last offset was saved) and show nothing.
const savedState = savedOffsets[sessionKey]; // eslint-disable-line security/detect-object-injection
let initialState;
if (completed) {
// Reactivation: start fresh from current file position (this turn only)
initialState = { agentId };
} else if (savedState) {
// Restart recovery: resume from saved offset
initialState = { lastOffset: savedState.lastOffset, startTime: savedState.startTime, agentId };
} else {
// New session: start from current file position
initialState = { agentId };
}
watcher.addSession(sessionKey, transcriptFile, initialState);
// Process any pending sub-agents that were waiting for this parent
if (pendingSubAgents.has(sessionKey)) {
const pending = pendingSubAgents.get(sessionKey);
pendingSubAgents.delete(sessionKey);
for (const childInfo of pending) {
logger.debug(
{ childKey: childInfo.sessionKey, parentKey: sessionKey },
'Processing queued sub-agent',
);
linkSubAgent(activeBoxes, watcher, sessionKey, childInfo, logger);
}
}
});
// ---- Session Removed ----
monitor.on('session-removed', (sessionKey) => {
// Don't immediately remove — let idle detection handle final flush
logger.debug({ sessionKey }, 'Session removed from sessions.json');
});
// ---- Ghost reactivation (from watcher fs.watch on completed session file) ----
// Fires immediately when the transcript file changes after a session completes.
// Clears the completedSessions cooldown so the next monitor poll re-detects instantly.
watcher.on('session-reactivate', (sessionKey, ghostPath) => {
logger.info({ sessionKey }, 'Ghost watch triggered reactivation — clearing completed cooldown');
monitor.clearCompleted(sessionKey);
// Force an immediate poll so the session is re-added without waiting 2s
monitor.pollNow();
// Clean up ghost entry now — clearCompleted+pollNow is sufficient, ghost served its purpose
if (ghostPath) watcher.fileToSession.delete(ghostPath);
});
// ---- Lock file reactivation (earliest possible trigger) ----
// The gateway writes a .jsonl.lock file the instant it starts processing a turn —
// before any JSONL content is written. This is the infrastructure-level signal
// that a session became active from user input, not from the first reply line.
watcher.on('session-lock', (sessionKey) => {
logger.info({ sessionKey }, 'Lock file: session activated by user message — early reactivation');
monitor.clearCompleted(sessionKey);
monitor.pollNow();
});
// Lock file for a session the watcher doesn't know about yet — look it up by path
watcher.on('session-lock-path', (jsonlPath) => {
const sessionKey = monitor.findSessionByFile(jsonlPath);
if (sessionKey) {
logger.info({ sessionKey }, 'Lock file (path lookup): session activated — early reactivation');
monitor.clearCompleted(sessionKey);
monitor.pollNow();
}
});
// ---- Lock file released: turn complete, mark done immediately ----
// Gateway deletes .jsonl.lock when the final reply is sent to the user.
// This is the most reliable "done" signal — no waiting for cache-ttl or 60s idle.
watcher.on('session-lock-released', (sessionKey) => {
logger.info({ sessionKey }, 'Lock file released — turn complete, triggering immediate idle');
watcher.triggerIdle(sessionKey);
});
watcher.on('session-lock-released-path', (jsonlPath) => {
const sessionKey = monitor.findSessionByFile(jsonlPath);
if (sessionKey) {
logger.info({ sessionKey }, 'Lock file released (path lookup) — triggering immediate idle');
watcher.triggerIdle(sessionKey);
}
});
// ---- Session Update (from watcher) ----
watcher.on('session-update', (sessionKey, state) => {
const box = activeBoxes.get(sessionKey);
if (!box) {
// Sub-agent: update parent
updateParentWithChild(activeBoxes, watcher, sharedStatusBox, pluginClient, sessionKey, state, logger);
return;
}
if (box.usePlugin && pluginClient) {
// Plugin mode: send structured data (WebSocket broadcast, no post edit)
pluginClient.updateSession(sessionKey, {
status: state.status,
lines: state.lines,
elapsed_ms: Date.now() - state.startTime,
token_count: state.tokenCount || 0,
children: (state.children || []).map(function (c) {
return { session_key: c.sessionKey, agent_id: c.agentId, status: c.status, lines: c.lines || [], elapsed_ms: Date.now() - (c.startTime || Date.now()), token_count: c.tokenCount || 0 };
}),
start_time_ms: state.startTime,
}).catch(function (err) {
// Only permanently disable plugin mode for hard failures (non-retryable errors).
// 429 and 5xx are transient — keep plugin mode and retry on next update.
if (err.statusCode === 429 || (err.statusCode >= 500 && err.statusCode < 600)) {
logger.warn({ sessionKey, statusCode: err.statusCode }, 'Plugin API transient error — keeping plugin mode, will retry next update');
// do NOT set box.usePlugin = false
} else {
logger.warn({ sessionKey, err: err.message }, 'Plugin API hard failure — falling back to REST');
box.usePlugin = false;
var fallbackText = buildStatusText(box, state, activeBoxes, watcher, sessionKey);
sharedStatusBox.updatePost(box.postId, fallbackText).catch(function () {});
}
});
} else {
// REST API fallback: format text and PUT update post
var text = buildStatusText(box, state, activeBoxes, watcher, sessionKey);
sharedStatusBox.updatePost(box.postId, text).catch(function (err) {
logger.error({ sessionKey, err }, 'Failed to update status post');
globalMetrics.lastError = err.message;
globalMetrics.updatesFailed++;
});
}
// Persist offsets periodically
saveOffsets(config.offsetFile, watcher.sessions);
});
// ---- Session Idle (from watcher) ----
watcher.on('session-idle', async (sessionKey, state) => {
const box = activeBoxes.get(sessionKey); // snapshot at entry
if (!box) {
// Sub-agent completed
updateParentWithChild(activeBoxes, watcher, sharedStatusBox, pluginClient, sessionKey, state, logger);
return;
}
// Check all children are complete before marking done
const allChildrenDone = checkChildrenComplete(box, watcher);
if (!allChildrenDone) {
logger.debug({ sessionKey }, 'Parent waiting for child sessions to complete');
return;
}
// Final update with done status
const doneState = { ...state, status: 'done' };
try {
if (box.usePlugin && pluginClient) {
// Plugin mode: mark session complete
await pluginClient.deleteSession(sessionKey);
logger.info({ sessionKey, postId: box.postId, mode: 'plugin' }, 'Session complete via plugin');
} else {
// REST API fallback
var text = buildStatusText(box, doneState, activeBoxes, watcher, sessionKey);
await sharedStatusBox.forceFlush(box.postId);
await sharedStatusBox.updatePost(box.postId, text);
logger.info({ sessionKey, postId: box.postId, mode: 'rest' }, 'Session complete — status box updated');
}
} catch (err) {
logger.error({ sessionKey, err }, 'Failed to update final status');
}
// Guard: if the box was replaced during the awaits above (new session reactivated),
// skip cleanup to avoid killing the newly re-added session.
if (activeBoxes.get(sessionKey) !== box) {
logger.info({ sessionKey }, 'session-idle: box replaced during await — skipping cleanup (new session active)');
return;
}
// Save to completedBoxes so we can reuse the post ID if the session reactivates
completedBoxes.set(sessionKey, {
postId: box.postId,
lastOffset: state.lastOffset || 0,
});
// Persist final offsets BEFORE removing session from watcher so the
// completed session's last offset is captured in the snapshot.
saveOffsets(config.offsetFile, watcher.sessions);
// Clean up active tracking
activeBoxes.delete(sessionKey);
watcher.removeSession(sessionKey);
// Forget from monitor so it CAN be re-detected — but completedBoxes
// ensures we reuse the existing post instead of creating a new one.
monitor.forgetSession(sessionKey);
globalMetrics.activeSessions = activeBoxes.size;
});
// ---- Start all subsystems ----
await healthServer.start();
try {
watcher.start();
} catch (err) {
logger.warn({ err }, 'fs.watch failed — creating transcript dir');
// eslint-disable-next-line security/detect-non-literal-fs-filename
fs.mkdirSync(config.transcriptDir, { recursive: true });
watcher.start();
}
monitor.start();
logger.info('Status watcher daemon ready');
// ---- Graceful shutdown ----
let shuttingDown = false;
async function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
logger.info({ signal }, 'Shutting down gracefully');
// Persist offsets for all active sessions before stopping the watcher
saveOffsets(config.offsetFile, watcher.sessions);
// Stop accepting new sessions
monitor.stop();
watcher.stop();
// Mark all active boxes as interrupted
const updates = [];
for (const [sessionKey, box] of activeBoxes) {
const state = watcher.getSessionState(sessionKey) || {
sessionKey,
status: 'interrupted',
startTime: Date.now(),
lines: [],
agentId: box.agentId,
depth: 0,
tokenCount: 0,
children: [],
};
const intState = { ...state, status: 'interrupted' };
const text = buildStatusText(box, intState, activeBoxes, watcher, sessionKey);
updates.push(
sharedStatusBox
.updatePost(box.postId, text)
.catch((e) => logger.error({ sessionKey, e }, 'Shutdown update failed')),
);
}
await Promise.allSettled(updates);
await sharedStatusBox.flushAll();
// Cleanup
await healthServer.stop();
sharedStatusBox.destroy();
if (pluginClient) pluginClient.destroy();
removePidFile(config.pidFile);
logger.info('Shutdown complete');
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// Offset persistence interval (every 30s)
setInterval(() => {
saveOffsets(config.offsetFile, watcher.sessions);
}, 30000);
}
// ---- Helper functions ----
function linkSubAgent(activeBoxes, watcher, parentKey, childInfo, logger) {
const parentBox = activeBoxes.get(parentKey);
if (!parentBox) return;
const { sessionKey, transcriptFile, agentId, spawnDepth } = childInfo;
if (!parentBox.children) parentBox.children = new Map();
parentBox.children.set(sessionKey, { sessionKey, transcriptFile, agentId });
watcher.addSession(sessionKey, transcriptFile, {
agentId,
depth: spawnDepth || 1,
});
logger.info({ sessionKey, parent: parentKey }, 'Sub-agent linked to parent');
}
function buildInitialText(agentId, sessionKey) {
const { format } = require('./status-formatter');
return format({
sessionKey,
status: 'active',
startTime: Date.now(),
lines: [],
agentId,
depth: 0,
tokenCount: 0,
children: [],
});
}
function buildStatusText(box, state, activeBoxes, watcher, _sessionKey) {
const { format } = require('./status-formatter');
// Build child states for nesting
const childStates = [];
if (box.children && box.children.size > 0) {
for (const [childKey] of box.children) {
const childWatcherState = watcher.getSessionState(childKey);
if (childWatcherState) {
childStates.push({ ...childWatcherState, depth: 1 });
}
}
}
return format({ ...state, children: childStates });
}
function updateParentWithChild(activeBoxes, watcher, statusBox, pluginClient, childKey, childState, logger) {
// Find parent
for (var entry of activeBoxes.entries()) {
var parentKey = entry[0];
var box = entry[1];
if (box.children && box.children.has(childKey)) {
var parentState = watcher.getSessionState(parentKey);
if (!parentState) return;
if (box.usePlugin && pluginClient) {
// Plugin mode: send structured update for parent
pluginClient.updateSession(parentKey, {
status: parentState.status,
lines: parentState.lines,
elapsed_ms: Date.now() - parentState.startTime,
token_count: parentState.tokenCount || 0,
children: [],
start_time_ms: parentState.startTime,
}).catch(function (err) { logger.error({ parentKey, childKey, err: err.message }, 'Failed to update parent via plugin'); });
} else {
var text = buildStatusText(box, parentState, activeBoxes, watcher, parentKey);
statusBox
.updatePost(box.postId, text)
.catch(function (err) { logger.error({ parentKey, childKey, err }, 'Failed to update parent'); });
}
return;
}
}
}
function checkChildrenComplete(box, watcher) {
if (!box.children || box.children.size === 0) return true;
for (const [childKey] of box.children) {
const childState = watcher.getSessionState(childKey);
if (childState && childState.status === 'active') return false;
}
return true;
}
async function findExistingPost(statusBox, channelId, sessionKey, logger) {
// Search channel history for a post with our marker
// This uses the Mattermost search API
const marker = makeMarker(sessionKey);
try {
// Use the internal _apiCall method to search posts
const result = await statusBox._apiCallWithRetry('POST', '/api/v4/posts/search', {
channel_id: channelId,
terms: marker,
is_or_search: false,
});
if (result && result.posts) {
for (const post of Object.values(result.posts)) {
if (post.message && post.message.includes(marker)) {
logger.info(
{ sessionKey, postId: post.id },
'Found existing status post (restart recovery)',
);
return post.id;
}
}
}
} catch (_e) {
/* search failed — create new post */
}
return null;
}
// ---- Stop command ----
function stopDaemon() {
// Need to read config for pidFile location
process.env.MM_BOT_TOKEN = process.env.MM_BOT_TOKEN || 'placeholder';
let config;
try {
config = getConfig();
} catch (_e) {
config = { pidFile: '/tmp/status-watcher.pid' };
}
const pid = readPidFile(config.pidFile);
if (!pid) {
console.log('Daemon not running (no PID file)');
return;
}
if (!isProcessRunning(pid)) {
console.log(`Daemon not running (PID ${pid} not found)`);
removePidFile(config.pidFile);
return;
}
console.log(`Stopping daemon (PID ${pid})...`);
process.kill(pid, 'SIGTERM');
}
// ---- Status command ----
function daemonStatus() {
process.env.MM_BOT_TOKEN = process.env.MM_BOT_TOKEN || 'placeholder';
let config;
try {
config = getConfig();
} catch (_e) {
config = { pidFile: '/tmp/status-watcher.pid', healthPort: 9090 };
}
const pid = readPidFile(config.pidFile);
if (!pid) {
console.log('Status: stopped');
return;
}
if (!isProcessRunning(pid)) {
console.log(`Status: stopped (stale PID ${pid})`);
return;
}
console.log(`Status: running (PID ${pid})`);
console.log(`Health: http://localhost:${config.healthPort}/health`);
}

78
start-daemon.sh Executable file
View 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:-/root/.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

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

@@ -0,0 +1,262 @@
'use strict';
/**
* Integration tests for session-monitor.js
* Tests session detection by writing mock sessions.json files.
*/
const { describe, it, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { SessionMonitor } = require('../../src/session-monitor');
function createTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'sm-test-'));
}
function writeSessionsJson(dir, agentId, sessions) {
const agentDir = path.join(dir, agentId, 'sessions');
fs.mkdirSync(agentDir, { recursive: true });
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) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe('SessionMonitor', () => {
let tmpDir;
let monitor;
beforeEach(() => {
tmpDir = createTmpDir();
});
afterEach(() => {
if (monitor) {
monitor.stop();
monitor = null;
}
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch (_e) {
/* ignore */
}
});
describe('parseChannelId()', () => {
it('parses channel ID from mattermost session key', () => {
const key = 'agent:main:mattermost:channel:abc123:thread:xyz';
assert.equal(SessionMonitor.parseChannelId(key), 'abc123');
});
it('parses DM channel', () => {
const key = 'agent:main:mattermost:dm:user456';
assert.equal(SessionMonitor.parseChannelId(key), 'user456');
});
it('returns null for non-MM session', () => {
const key = 'agent:main:hook:session:xyz';
assert.equal(SessionMonitor.parseChannelId(key), null);
});
});
describe('parseRootPostId()', () => {
it('parses thread ID from session key', () => {
const key = 'agent:main:mattermost:channel:abc123:thread:rootpost999';
assert.equal(SessionMonitor.parseRootPostId(key), 'rootpost999');
});
it('returns null if no thread', () => {
const key = 'agent:main:mattermost:channel:abc123';
assert.equal(SessionMonitor.parseRootPostId(key), null);
});
});
describe('parseAgentId()', () => {
it('extracts agent ID', () => {
assert.equal(SessionMonitor.parseAgentId('agent:main:mattermost:channel:abc'), 'main');
assert.equal(SessionMonitor.parseAgentId('agent:coder-agent:session'), 'coder-agent');
});
});
describe('isMattermostSession()', () => {
it('detects mattermost sessions', () => {
assert.equal(SessionMonitor.isMattermostSession('agent:main:mattermost:channel:abc'), true);
assert.equal(SessionMonitor.isMattermostSession('agent:main:hook:abc'), false);
});
});
describe('session-added event', () => {
it('emits session-added for new session in sessions.json', async () => {
const sessionKey = 'agent:main:mattermost:channel:testchan:thread:testroot';
const sessionId = 'aaaa-1111-bbbb-2222';
writeSessionsJson(tmpDir, 'main', {
[sessionKey]: {
sessionId,
spawnedBy: null,
spawnDepth: 0,
label: null,
channel: 'mattermost',
},
});
monitor = new SessionMonitor({
transcriptDir: tmpDir,
pollMs: 50,
});
const added = [];
monitor.on('session-added', (info) => added.push(info));
monitor.start();
await sleep(200);
assert.equal(added.length, 1);
assert.equal(added[0].sessionKey, sessionKey);
assert.equal(added[0].channelId, 'testchan');
assert.equal(added[0].rootPostId, 'testroot');
assert.ok(added[0].transcriptFile.endsWith(`${sessionId}.jsonl`));
});
it('emits session-removed when session disappears', async () => {
const sessionKey = 'agent:main:mattermost:channel:testchan:thread:testroot';
const sessionId = 'cccc-3333-dddd-4444';
writeSessionsJson(tmpDir, 'main', {
[sessionKey]: { sessionId, spawnedBy: null, spawnDepth: 0, channel: 'mattermost' },
});
monitor = new SessionMonitor({ transcriptDir: tmpDir, pollMs: 50 });
const removed = [];
monitor.on('session-removed', (key) => removed.push(key));
monitor.start();
await sleep(200);
assert.equal(removed.length, 0);
// Remove the session
writeSessionsJson(tmpDir, 'main', {});
await sleep(200);
assert.equal(removed.length, 1);
assert.equal(removed[0], sessionKey);
});
it('skips non-MM sessions with no default channel', async () => {
const sessionKey = 'agent:main:hook:session:xyz';
writeSessionsJson(tmpDir, 'main', {
[sessionKey]: { sessionId: 'hook-uuid', channel: 'hook' },
});
monitor = new SessionMonitor({ transcriptDir: tmpDir, pollMs: 50, defaultChannel: null });
const added = [];
monitor.on('session-added', (info) => added.push(info));
monitor.start();
await sleep(200);
assert.equal(added.length, 0);
});
it('includes non-MM sessions when defaultChannel is set', async () => {
const sessionKey = 'agent:main:hook:session:xyz';
writeSessionsJson(tmpDir, 'main', {
[sessionKey]: { sessionId: 'hook-uuid', channel: 'hook' },
});
monitor = new SessionMonitor({
transcriptDir: tmpDir,
pollMs: 50,
defaultChannel: 'default-channel-id',
});
const added = [];
monitor.on('session-added', (info) => added.push(info));
monitor.start();
await sleep(200);
assert.equal(added.length, 1);
assert.equal(added[0].channelId, 'default-channel-id');
});
it('detects multiple agents', async () => {
writeSessionsJson(tmpDir, 'main', {
'agent:main:mattermost:channel:c1:thread:t1': {
sessionId: 'sess-main',
spawnedBy: null,
channel: 'mattermost',
},
});
writeSessionsJson(tmpDir, 'coder-agent', {
'agent:coder-agent:mattermost:channel:c2:thread:t2': {
sessionId: 'sess-coder',
spawnedBy: null,
channel: 'mattermost',
},
});
monitor = new SessionMonitor({ transcriptDir: tmpDir, pollMs: 50 });
const added = [];
monitor.on('session-added', (info) => added.push(info));
monitor.start();
await sleep(200);
assert.equal(added.length, 2);
const keys = added.map((s) => s.sessionKey).sort();
assert.ok(keys.includes('agent:main:mattermost:channel:c1:thread:t1'));
assert.ok(keys.includes('agent:coder-agent:mattermost:channel:c2:thread:t2'));
});
it('handles malformed sessions.json gracefully', async () => {
const agentDir = path.join(tmpDir, 'main', 'sessions');
fs.mkdirSync(agentDir, { recursive: true });
fs.writeFileSync(path.join(agentDir, 'sessions.json'), 'not valid json');
monitor = new SessionMonitor({ transcriptDir: tmpDir, pollMs: 50 });
const added = [];
monitor.on('session-added', (info) => added.push(info));
monitor.start();
await sleep(200);
// Should not throw and should produce no sessions
assert.equal(added.length, 0);
});
});
describe('getKnownSessions()', () => {
it('returns current known sessions', async () => {
const sessionKey = 'agent:main:mattermost:channel:c1:thread:t1';
writeSessionsJson(tmpDir, 'main', {
[sessionKey]: { sessionId: 'test-uuid', channel: 'mattermost' },
});
monitor = new SessionMonitor({ transcriptDir: tmpDir, pollMs: 50 });
monitor.start();
await sleep(200);
const sessions = monitor.getKnownSessions();
assert.equal(sessions.size, 1);
assert.ok(sessions.has(sessionKey));
});
});
});

View File

@@ -0,0 +1,269 @@
'use strict';
/**
* Integration tests for status-watcher.js
* Tests JSONL file watching and event emission.
*/
const { describe, it, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { StatusWatcher } = require('../../src/status-watcher');
function createTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'sw-test-'));
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function appendLine(file, obj) {
fs.appendFileSync(file, JSON.stringify(obj) + '\n');
}
describe('StatusWatcher', () => {
let tmpDir;
let watcher;
beforeEach(() => {
tmpDir = createTmpDir();
});
afterEach(() => {
if (watcher) {
watcher.stop();
watcher = null;
}
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch (_e) {
/* ignore */
}
});
describe('session management', () => {
it('addSession() registers a session', () => {
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
const file = path.join(tmpDir, 'test.jsonl');
fs.writeFileSync(file, '');
watcher.addSession('test:key', file);
assert.ok(watcher.sessions.has('test:key'));
assert.ok(watcher.fileToSession.has(file));
});
it('removeSession() cleans up', () => {
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
const file = path.join(tmpDir, 'test.jsonl');
fs.writeFileSync(file, '');
watcher.addSession('test:key', file);
watcher.removeSession('test:key');
assert.ok(!watcher.sessions.has('test:key'));
assert.ok(!watcher.fileToSession.has(file));
});
it('getSessionState() returns state', () => {
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
const file = path.join(tmpDir, 'test.jsonl');
fs.writeFileSync(file, '');
watcher.addSession('test:key', file);
const state = watcher.getSessionState('test:key');
assert.ok(state);
assert.equal(state.sessionKey, 'test:key');
});
});
describe('JSONL parsing', () => {
it('reads existing content on addSession', () => {
const file = path.join(tmpDir, 'test.jsonl');
fs.writeFileSync(file, '');
appendLine(file, { type: 'assistant', text: 'Hello world' });
appendLine(file, { type: 'tool_call', name: 'exec', id: '1' });
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
// lastOffset: 0 explicitly reads from beginning (no lastOffset = skip to end)
watcher.addSession('test:key', file, { lastOffset: 0 });
const state = watcher.getSessionState('test:key');
assert.ok(state.lines.length > 0);
assert.ok(state.lines.some((l) => l.includes('exec')));
});
it('emits session-update when file changes', async () => {
const file = path.join(tmpDir, 'test.jsonl');
fs.writeFileSync(file, '');
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
watcher.start();
watcher.addSession('test:key', file);
const updates = [];
watcher.on('session-update', (key, state) => updates.push({ key, state }));
await sleep(100);
appendLine(file, { type: 'assistant', text: 'Starting task...' });
await sleep(500);
assert.ok(updates.length > 0);
assert.equal(updates[0].key, 'test:key');
});
it('parses tool_call and tool_result pairs', () => {
const file = path.join(tmpDir, 'test.jsonl');
fs.writeFileSync(file, '');
appendLine(file, { type: 'tool_call', name: 'exec', id: '1' });
appendLine(file, { type: 'tool_result', name: 'exec', id: '1' });
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
watcher.addSession('test:key', file, { lastOffset: 0 });
const state = watcher.getSessionState('test:key');
assert.equal(state.pendingToolCalls, 0);
// Line should show [OK]
const execLine = state.lines.find((l) => l.includes('exec'));
assert.ok(execLine);
assert.ok(execLine.includes('[OK]'));
});
it('tracks pendingToolCalls correctly', () => {
const file = path.join(tmpDir, 'test.jsonl');
fs.writeFileSync(file, '');
appendLine(file, { type: 'tool_call', name: 'exec', id: '1' });
appendLine(file, { type: 'tool_call', name: 'Read', id: '2' });
appendLine(file, { type: 'tool_result', name: 'exec', id: '1' });
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
watcher.addSession('test:key', file, { lastOffset: 0 });
const state = watcher.getSessionState('test:key');
assert.equal(state.pendingToolCalls, 1); // Read still pending
});
it('tracks token usage', () => {
const file = path.join(tmpDir, 'test.jsonl');
fs.writeFileSync(file, '');
appendLine(file, { type: 'usage', input_tokens: 1000, output_tokens: 500 });
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
watcher.addSession('test:key', file, { lastOffset: 0 });
const state = watcher.getSessionState('test:key');
assert.equal(state.tokenCount, 1500);
});
it('detects file truncation (compaction)', () => {
const file = path.join(tmpDir, 'test.jsonl');
// Write some content
fs.writeFileSync(
file,
JSON.stringify({ type: 'assistant', text: 'Hello' }) +
'\n' +
JSON.stringify({ type: 'tool_call', name: 'exec', id: '1' }) +
'\n',
);
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
watcher.addSession('test:key', file);
const state = watcher.getSessionState('test:key');
const originalOffset = state.lastOffset;
assert.ok(originalOffset > 0);
// Truncate the file (simulate compaction)
fs.writeFileSync(file, JSON.stringify({ type: 'assistant', text: 'Resumed' }) + '\n');
// Force a re-read
watcher._readFile('test:key', state);
// Offset should have reset
assert.ok(
state.lastOffset < originalOffset ||
state.lines.includes('[session compacted - continuing]'),
);
});
it('handles malformed JSONL lines gracefully', () => {
const file = path.join(tmpDir, 'test.jsonl');
fs.writeFileSync(file, 'not valid json\n{"type":"assistant","text":"ok"}\n');
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
// Should not throw
assert.doesNotThrow(() => {
watcher.addSession('test:key', file, { lastOffset: 0 });
});
const state = watcher.getSessionState('test:key');
assert.ok(state);
assert.ok(state.lines.some((l) => l.includes('ok')));
});
});
describe('idle detection', () => {
it('emits session-idle after timeout with no activity', async () => {
const file = path.join(tmpDir, 'test.jsonl');
fs.writeFileSync(file, '');
appendLine(file, { type: 'assistant', text: 'Starting...' });
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 0.2 }); // 200ms
// lastOffset: 0 so the pre-written content is read and idle timer is scheduled
watcher.addSession('test:key', file, { lastOffset: 0 });
const idle = [];
watcher.on('session-idle', (key) => idle.push(key));
await sleep(600);
assert.ok(idle.length > 0);
assert.equal(idle[0], 'test:key');
});
it('resets idle timer on new activity', async () => {
const file = path.join(tmpDir, 'test.jsonl');
fs.writeFileSync(file, '');
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 0.3 }); // 300ms
watcher.start();
watcher.addSession('test:key', file);
const idle = [];
watcher.on('session-idle', (key) => idle.push(key));
// Write activity at 150ms (before 300ms timeout)
await sleep(150);
appendLine(file, { type: 'assistant', text: 'Still working...' });
// At 350ms: idle timer was reset, so no idle yet
await sleep(200);
assert.equal(idle.length, 0);
// At 600ms: idle timer fires (150+300=450ms > 200+300=500ms... need to wait full 300ms)
await sleep(400);
assert.equal(idle.length, 1);
});
});
describe('offset recovery', () => {
it('resumes from saved offset', () => {
const file = path.join(tmpDir, 'test.jsonl');
const line1 = JSON.stringify({ type: 'assistant', text: 'First' }) + '\n';
const line2 = JSON.stringify({ type: 'assistant', text: 'Second' }) + '\n';
fs.writeFileSync(file, line1 + line2);
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
// Start with offset at start of line2 (after line1)
watcher.addSession('test:key', file, { lastOffset: line1.length });
const state = watcher.getSessionState('test:key');
// Should only have parsed line2
assert.ok(state.lines.some((l) => l.includes('Second')));
assert.ok(!state.lines.some((l) => l.includes('First')));
});
});
});

View File

@@ -0,0 +1,369 @@
'use strict';
/**
* Integration tests for sub-agent support (Phase 3).
*
* Tests:
* 3.1 Sub-agent detection via spawnedBy in sessions.json
* 3.2 Nested status rendering in status-formatter
* 3.3 Cascade completion: parent waits for all children
* 3.4 Status watcher handles child session transcript
*/
const { describe, it, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { SessionMonitor } = require('../../src/session-monitor');
const { StatusWatcher } = require('../../src/status-watcher');
const { format } = require('../../src/status-formatter');
function createTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'subagent-test-'));
}
function writeSessionsJson(dir, agentId, sessions) {
var agentDir = path.join(dir, agentId, 'sessions');
fs.mkdirSync(agentDir, { recursive: true });
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) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function appendLine(file, obj) {
fs.appendFileSync(file, JSON.stringify(obj) + '\n');
}
describe('Sub-Agent Support (Phase 3)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTmpDir();
});
afterEach(() => {
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch (_e) {
/* ignore */
}
});
describe('3.1 Sub-agent detection via spawnedBy', () => {
it('detects sub-agent session with spawnedBy field', async () => {
const parentKey = 'agent:main:mattermost:channel:chan1:thread:root1';
const childKey = 'agent:main:subagent:uuid-child-123';
const parentId = 'parent-uuid';
const childId = 'child-uuid';
writeSessionsJson(tmpDir, 'main', {
[parentKey]: {
sessionId: parentId,
spawnedBy: null,
spawnDepth: 0,
channel: 'mattermost',
},
[childKey]: {
sessionId: childId,
spawnedBy: parentKey,
spawnDepth: 1,
label: 'proj035-planner',
channel: 'mattermost',
},
});
const monitor = new SessionMonitor({ transcriptDir: tmpDir, pollMs: 50 });
const added = [];
monitor.on('session-added', (info) => added.push(info));
monitor.start();
await sleep(200);
monitor.stop();
// Both sessions should be detected
assert.equal(added.length, 2);
const child = added.find((s) => s.sessionKey === childKey);
assert.ok(child);
assert.equal(child.spawnedBy, parentKey);
assert.equal(child.spawnDepth, 1);
assert.equal(child.agentId, 'proj035-planner');
});
it('sub-agent inherits parent channel ID', async () => {
const parentKey = 'agent:main:mattermost:channel:mychan:thread:myroot';
const childKey = 'agent:main:subagent:child-uuid';
writeSessionsJson(tmpDir, 'main', {
[parentKey]: {
sessionId: 'p-uuid',
spawnedBy: null,
channel: 'mattermost',
},
[childKey]: {
sessionId: 'c-uuid',
spawnedBy: parentKey,
spawnDepth: 1,
channel: 'mattermost',
},
});
const monitor = new SessionMonitor({ transcriptDir: tmpDir, pollMs: 50 });
const added = [];
monitor.on('session-added', (info) => added.push(info));
monitor.start();
await sleep(200);
monitor.stop();
const child = added.find((s) => s.sessionKey === childKey);
assert.ok(child);
// Child session key has the parent's channel embedded in spawnedBy key
assert.equal(child.spawnedBy, parentKey);
});
});
describe('3.2 Nested status rendering', () => {
it('renders parent with nested child status', () => {
const parentState = {
sessionKey: 'agent:main:mattermost:channel:c1:thread:t1',
status: 'active',
startTime: Date.now() - 60000,
lines: ['Starting implementation...', ' exec: ls [OK]'],
agentId: 'main',
depth: 0,
tokenCount: 5000,
children: [
{
sessionKey: 'agent:main:subagent:planner-uuid',
status: 'done',
startTime: Date.now() - 30000,
lines: ['Reading protocol...', ' Read: PLAN.md [OK]'],
agentId: 'proj035-planner',
depth: 1,
tokenCount: 2000,
children: [],
},
],
};
const result = format(parentState);
const lines = result.split('\n');
// Parent header at depth 0 — wrapped in blockquote ("> ")
assert.ok(lines[0].includes('[ACTIVE]'));
assert.ok(lines[0].includes('main'));
// Child header at depth 1 (indented with "> " + 2 spaces)
const childHeaderLine = lines.find((l) => l.includes('proj035-planner'));
assert.ok(childHeaderLine);
// Child lines appear as "> ..." (blockquote + 2-space indent)
assert.ok(/^> {2}/.test(childHeaderLine));
// Child should have [DONE] line
assert.ok(result.includes('[DONE]'));
// Parent should not have [DONE] footer (still active)
// The top-level [DONE] is from child, wrapped in blockquote
const parentDoneLines = lines.filter((l) => /^\[DONE\]/.test(l));
assert.equal(parentDoneLines.length, 0);
});
it('renders multiple nested children', () => {
const child1 = {
sessionKey: 'agent:main:subagent:child1',
status: 'done',
startTime: Date.now() - 20000,
lines: ['Child 1 done'],
agentId: 'child-agent-1',
depth: 1,
tokenCount: 1000,
children: [],
};
const child2 = {
sessionKey: 'agent:main:subagent:child2',
status: 'active',
startTime: Date.now() - 10000,
lines: ['Child 2 working...'],
agentId: 'child-agent-2',
depth: 1,
tokenCount: 500,
children: [],
};
const parent = {
sessionKey: 'agent:main:mattermost:channel:c1:thread:t1',
status: 'active',
startTime: Date.now() - 30000,
lines: ['Orchestrating...'],
agentId: 'main',
depth: 0,
tokenCount: 3000,
children: [child1, child2],
};
const result = format(parent);
assert.ok(result.includes('child-agent-1'));
assert.ok(result.includes('child-agent-2'));
});
it('indents deeply nested children', () => {
const grandchild = {
sessionKey: 'agent:main:subagent:grandchild',
status: 'active',
startTime: Date.now() - 5000,
lines: ['Deep work...'],
agentId: 'grandchild-agent',
depth: 2,
tokenCount: 100,
children: [],
};
const child = {
sessionKey: 'agent:main:subagent:child',
status: 'active',
startTime: Date.now() - 15000,
lines: ['Spawning grandchild...'],
agentId: 'child-agent',
depth: 1,
tokenCount: 500,
children: [grandchild],
};
const parent = {
sessionKey: 'agent:main:mattermost:channel:c1:thread:t1',
status: 'active',
startTime: Date.now() - 30000,
lines: ['Top level'],
agentId: 'main',
depth: 0,
tokenCount: 2000,
children: [child],
};
const result = format(parent);
const lines = result.split('\n');
const grandchildLine = lines.find((l) => l.includes('grandchild-agent'));
assert.ok(grandchildLine);
// Grandchild at depth 2 should have 4 spaces of indent after blockquote prefix ("> ")
assert.ok(/^> {4}/.test(grandchildLine));
});
});
describe('3.3 Cascade completion', () => {
it('status-watcher tracks pending tool calls across child sessions', () => {
const parentFile = path.join(tmpDir, 'parent.jsonl');
const childFile = path.join(tmpDir, 'child.jsonl');
fs.writeFileSync(parentFile, '');
fs.writeFileSync(childFile, '');
appendLine(parentFile, { type: 'assistant', text: 'Starting...' });
appendLine(childFile, { type: 'tool_call', name: 'exec', id: '1' });
const watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
try {
watcher.addSession('parent:key', parentFile, { depth: 0, lastOffset: 0 });
watcher.addSession('child:key', childFile, { depth: 1, lastOffset: 0 });
const parentState = watcher.getSessionState('parent:key');
const childState = watcher.getSessionState('child:key');
assert.ok(parentState);
assert.ok(childState);
assert.equal(childState.pendingToolCalls, 1);
assert.equal(parentState.pendingToolCalls, 0);
} finally {
watcher.stop();
}
});
it('child session idle does not affect parent tracking', async () => {
const parentFile = path.join(tmpDir, 'parent2.jsonl');
const childFile = path.join(tmpDir, 'child2.jsonl');
fs.writeFileSync(parentFile, '');
fs.writeFileSync(childFile, '');
appendLine(parentFile, { type: 'assistant', text: 'Orchestrating' });
appendLine(childFile, { type: 'assistant', text: 'Child working' });
const watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 0.2 });
const idleEvents = [];
watcher.on('session-idle', (key) => idleEvents.push(key));
try {
watcher.addSession('parent2:key', parentFile, { depth: 0, lastOffset: 0 });
watcher.addSession('child2:key', childFile, { depth: 1, lastOffset: 0 });
await sleep(600);
// Both should eventually idle (since both have no pending tool calls)
assert.ok(idleEvents.includes('child2:key') || idleEvents.includes('parent2:key'));
} finally {
watcher.stop();
}
});
});
describe('3.4 Sub-agent JSONL parsing', () => {
it('correctly parses sub-agent transcript with usage events', () => {
const file = path.join(tmpDir, 'subagent.jsonl');
fs.writeFileSync(file, '');
appendLine(file, { type: 'assistant', text: 'Reading PLAN.md...' });
appendLine(file, { type: 'tool_call', name: 'Read', id: '1' });
appendLine(file, { type: 'tool_result', name: 'Read', id: '1' });
appendLine(file, { type: 'usage', input_tokens: 2000, output_tokens: 800 });
appendLine(file, { type: 'assistant', text: 'Plan ready.' });
const watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
try {
watcher.addSession('subagent:key', file, { agentId: 'planner', depth: 1, lastOffset: 0 });
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('Read') && l.includes('[OK]')));
assert.ok(state.lines.some((l) => l.includes('Plan ready')));
assert.equal(state.tokenCount, 2800);
assert.equal(state.pendingToolCalls, 0);
} finally {
watcher.stop();
}
});
it('handles error tool result', () => {
const file = path.join(tmpDir, 'err.jsonl');
fs.writeFileSync(file, '');
appendLine(file, { type: 'tool_call', name: 'exec', id: '1' });
appendLine(file, { type: 'tool_result', name: 'exec', id: '1', error: true });
const watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
try {
watcher.addSession('err:key', file, { lastOffset: 0 });
const state = watcher.getSessionState('err:key');
const execLine = state.lines.find((l) => l.includes('exec'));
assert.ok(execLine);
assert.ok(execLine.includes('[ERR]'));
assert.equal(state.pendingToolCalls, 0);
} finally {
watcher.stop();
}
});
});
});

View File

@@ -0,0 +1,171 @@
'use strict';
/**
* Unit tests for circuit-breaker.js
*/
const { describe, it, beforeEach } = require('node:test');
const assert = require('node:assert/strict');
const { CircuitBreaker, CircuitOpenError, STATE } = require('../../src/circuit-breaker');
function makeBreaker(opts = {}) {
return new CircuitBreaker({ threshold: 3, cooldownMs: 100, ...opts });
}
describe('CircuitBreaker', () => {
let breaker;
beforeEach(() => {
breaker = makeBreaker();
});
it('starts in CLOSED state', () => {
assert.equal(breaker.getState(), STATE.CLOSED);
assert.equal(breaker.failures, 0);
});
it('executes successfully in CLOSED state', async () => {
const result = await breaker.execute(async () => 42);
assert.equal(result, 42);
assert.equal(breaker.getState(), STATE.CLOSED);
assert.equal(breaker.failures, 0);
});
it('tracks failures below threshold', async () => {
const failFn = async () => {
throw new Error('fail');
};
await assert.rejects(() => breaker.execute(failFn));
await assert.rejects(() => breaker.execute(failFn));
assert.equal(breaker.getState(), STATE.CLOSED);
assert.equal(breaker.failures, 2);
});
it('transitions to OPEN after threshold failures', async () => {
const failFn = async () => {
throw new Error('fail');
};
for (let i = 0; i < 3; i++) {
await assert.rejects(() => breaker.execute(failFn));
}
assert.equal(breaker.getState(), STATE.OPEN);
});
it('rejects calls immediately when OPEN', async () => {
const failFn = async () => {
throw new Error('fail');
};
for (let i = 0; i < 3; i++) {
await assert.rejects(() => breaker.execute(failFn));
}
assert.equal(breaker.getState(), STATE.OPEN);
await assert.rejects(() => breaker.execute(async () => 'should not run'), CircuitOpenError);
});
it('transitions to HALF_OPEN after cooldown', async () => {
const failFn = async () => {
throw new Error('fail');
};
for (let i = 0; i < 3; i++) {
await assert.rejects(() => breaker.execute(failFn));
}
assert.equal(breaker.getState(), STATE.OPEN);
// Wait for cooldown
await sleep(150);
// Next call transitions to HALF_OPEN and executes
const result = await breaker.execute(async () => 'probe');
assert.equal(result, 'probe');
assert.equal(breaker.getState(), STATE.CLOSED);
});
it('transitions HALF_OPEN -> OPEN if probe fails', async () => {
const failFn = async () => {
throw new Error('fail');
};
for (let i = 0; i < 3; i++) {
await assert.rejects(() => breaker.execute(failFn));
}
assert.equal(breaker.getState(), STATE.OPEN);
await sleep(150);
// Probe fails
await assert.rejects(() => breaker.execute(failFn));
assert.equal(breaker.getState(), STATE.OPEN);
});
it('resets on success after HALF_OPEN', async () => {
const failFn = async () => {
throw new Error('fail');
};
for (let i = 0; i < 3; i++) {
await assert.rejects(() => breaker.execute(failFn));
}
await sleep(150);
await breaker.execute(async () => 'ok');
assert.equal(breaker.getState(), STATE.CLOSED);
assert.equal(breaker.failures, 0);
});
it('calls onStateChange callback on transitions', async () => {
const changes = [];
breaker = makeBreaker({
onStateChange: (newState, oldState) => changes.push({ newState, oldState }),
});
const failFn = async () => {
throw new Error('fail');
};
for (let i = 0; i < 3; i++) {
await assert.rejects(() => breaker.execute(failFn));
}
assert.equal(changes.length, 1);
assert.equal(changes[0].newState, STATE.OPEN);
assert.equal(changes[0].oldState, STATE.CLOSED);
});
it('reset() returns to CLOSED', async () => {
const failFn = async () => {
throw new Error('fail');
};
for (let i = 0; i < 3; i++) {
await assert.rejects(() => breaker.execute(failFn));
}
assert.equal(breaker.getState(), STATE.OPEN);
breaker.reset();
assert.equal(breaker.getState(), STATE.CLOSED);
assert.equal(breaker.failures, 0);
});
it('getMetrics() returns correct data', () => {
const metrics = breaker.getMetrics();
assert.equal(metrics.state, STATE.CLOSED);
assert.equal(metrics.failures, 0);
assert.equal(metrics.threshold, 3);
assert.equal(metrics.openedAt, null);
assert.equal(metrics.lastError, null);
});
it('getMetrics() reflects open state', async () => {
const failFn = async () => {
throw new Error('test error');
};
for (let i = 0; i < 3; i++) {
await assert.rejects(() => breaker.execute(failFn));
}
const metrics = breaker.getMetrics();
assert.equal(metrics.state, STATE.OPEN);
assert.ok(metrics.openedAt > 0);
assert.equal(metrics.lastError, 'test error');
});
});
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

135
test/unit/config.test.js Normal file
View File

@@ -0,0 +1,135 @@
'use strict';
/**
* Unit tests for config.js
*/
const { describe, it, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const { buildConfig, resetConfig } = require('../../src/config');
describe('config.js', () => {
const originalEnv = {};
beforeEach(() => {
// Save and clear relevant env vars
const keys = [
'MM_BOT_TOKEN',
'MM_BASE_URL',
'MM_MAX_SOCKETS',
'TRANSCRIPT_DIR',
'THROTTLE_MS',
'IDLE_TIMEOUT_S',
'SESSION_POLL_MS',
'MAX_ACTIVE_SESSIONS',
'MAX_MESSAGE_CHARS',
'MAX_STATUS_LINES',
'MAX_RETRIES',
'CIRCUIT_BREAKER_THRESHOLD',
'CIRCUIT_BREAKER_COOLDOWN_S',
'HEALTH_PORT',
'LOG_LEVEL',
'PID_FILE',
'OFFSET_FILE',
'TOOL_LABELS_FILE',
'DEFAULT_CHANNEL',
'ENABLE_FS_WATCH',
'MM_PORT',
];
for (const k of keys) {
originalEnv[k] = process.env[k];
delete process.env[k];
}
resetConfig();
});
afterEach(() => {
// Restore env
for (const [k, v] of Object.entries(originalEnv)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
resetConfig();
});
it('throws if MM_BOT_TOKEN is missing', () => {
assert.throws(() => buildConfig(), /MM_BOT_TOKEN/);
});
it('builds config with only required vars', () => {
process.env.MM_BOT_TOKEN = 'test-token';
const cfg = buildConfig();
assert.equal(cfg.mm.token, 'test-token');
assert.equal(cfg.mm.baseUrl, 'https://slack.solio.tech');
assert.equal(cfg.throttleMs, 500);
assert.equal(cfg.idleTimeoutS, 60);
assert.equal(cfg.maxActiveSessions, 20);
assert.equal(cfg.healthPort, 9090);
assert.equal(cfg.logLevel, 'info');
});
it('reads all env vars correctly', () => {
process.env.MM_BOT_TOKEN = 'mytoken';
process.env.MM_BASE_URL = 'https://mm.example.com';
process.env.MM_MAX_SOCKETS = '8';
process.env.THROTTLE_MS = '250';
process.env.IDLE_TIMEOUT_S = '120';
process.env.MAX_ACTIVE_SESSIONS = '10';
process.env.MAX_MESSAGE_CHARS = '5000';
process.env.LOG_LEVEL = 'debug';
process.env.HEALTH_PORT = '8080';
process.env.ENABLE_FS_WATCH = 'false';
const cfg = buildConfig();
assert.equal(cfg.mm.token, 'mytoken');
assert.equal(cfg.mm.baseUrl, 'https://mm.example.com');
assert.equal(cfg.mm.maxSockets, 8);
assert.equal(cfg.throttleMs, 250);
assert.equal(cfg.idleTimeoutS, 120);
assert.equal(cfg.maxActiveSessions, 10);
assert.equal(cfg.maxMessageChars, 5000);
assert.equal(cfg.logLevel, 'debug');
assert.equal(cfg.healthPort, 8080);
assert.equal(cfg.enableFsWatch, false);
});
it('throws on invalid MM_BASE_URL', () => {
process.env.MM_BOT_TOKEN = 'token';
process.env.MM_BASE_URL = 'not-a-url';
assert.throws(() => buildConfig(), /MM_BASE_URL/);
});
it('throws on non-integer THROTTLE_MS', () => {
process.env.MM_BOT_TOKEN = 'token';
process.env.THROTTLE_MS = 'abc';
assert.throws(() => buildConfig(), /THROTTLE_MS/);
});
it('ENABLE_FS_WATCH accepts "1", "true", "yes"', () => {
process.env.MM_BOT_TOKEN = 'token';
process.env.ENABLE_FS_WATCH = '1';
assert.equal(buildConfig().enableFsWatch, true);
resetConfig();
process.env.ENABLE_FS_WATCH = 'true';
assert.equal(buildConfig().enableFsWatch, true);
resetConfig();
process.env.ENABLE_FS_WATCH = 'yes';
assert.equal(buildConfig().enableFsWatch, true);
resetConfig();
process.env.ENABLE_FS_WATCH = '0';
assert.equal(buildConfig().enableFsWatch, false);
resetConfig();
});
it('nullish defaults for optional string fields', () => {
process.env.MM_BOT_TOKEN = 'token';
const cfg = buildConfig();
assert.equal(cfg.toolLabelsFile, null);
assert.equal(cfg.defaultChannel, null);
});
});

57
test/unit/logger.test.js Normal file
View File

@@ -0,0 +1,57 @@
'use strict';
/**
* Unit tests for logger.js
*/
const { describe, it, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const { getLogger, sessionLogger, resetLogger } = require('../../src/logger');
describe('logger.js', () => {
beforeEach(() => {
resetLogger();
});
afterEach(() => {
resetLogger();
});
it('getLogger() returns a pino logger', () => {
const logger = getLogger();
assert.ok(logger);
assert.equal(typeof logger.info, 'function');
assert.equal(typeof logger.warn, 'function');
assert.equal(typeof logger.error, 'function');
assert.equal(typeof logger.debug, 'function');
});
it('getLogger() returns the same instance each time (singleton)', () => {
const a = getLogger();
const b = getLogger();
assert.equal(a, b);
});
it('respects LOG_LEVEL env var', () => {
const original = process.env.LOG_LEVEL;
process.env.LOG_LEVEL = 'warn';
const logger = getLogger();
assert.equal(logger.level, 'warn');
process.env.LOG_LEVEL = original;
resetLogger();
});
it('sessionLogger() returns a child logger', () => {
const child = sessionLogger('agent:main:test');
assert.ok(child);
assert.equal(typeof child.info, 'function');
});
it('resetLogger() clears the singleton', () => {
const a = getLogger();
resetLogger();
const b = getLogger();
assert.notEqual(a, b);
});
});

View File

@@ -0,0 +1,208 @@
'use strict';
/**
* Unit tests for status-formatter.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const {
format,
formatElapsed,
formatTokens,
statusIcon,
truncateLine,
extractAgentId,
} = require('../../src/status-formatter');
const NOW = Date.now();
function makeState(overrides = {}) {
return {
sessionKey: 'agent:main:mattermost:channel:abc:thread:xyz',
status: 'active',
startTime: NOW - 38000, // 38s ago
lines: [],
children: [],
agentId: 'main',
depth: 0,
tokenCount: 0,
...overrides,
};
}
describe('status-formatter.js', () => {
describe('format()', () => {
it('formats active session with header', () => {
const state = makeState();
const result = format(state);
assert.ok(result.includes('[ACTIVE]'));
assert.ok(result.includes('main'));
assert.ok(result.match(/\d+s/));
});
it('formats done session with footer', () => {
const state = makeState({ status: 'done' });
const result = format(state);
assert.ok(result.includes('[DONE]'));
});
it('formats error session', () => {
const state = makeState({ status: 'error' });
const result = format(state);
assert.ok(result.includes('[ERROR]'));
});
it('formats interrupted session', () => {
const state = makeState({ status: 'interrupted' });
const result = format(state);
assert.ok(result.includes('[INTERRUPTED]'));
});
it('includes status lines', () => {
const state = makeState({
lines: ['Reading files...', ' exec: ls [OK]', 'Writing results...'],
});
const result = format(state);
assert.ok(result.includes('Reading files...'));
assert.ok(result.includes('exec: ls [OK]'));
assert.ok(result.includes('Writing results...'));
});
it('limits status lines to maxLines', () => {
const lines = Array.from({ length: 30 }, (_, i) => `Line ${i + 1}`);
const state = makeState({ lines });
const result = format(state, { maxLines: 5 });
// Only last 5 lines should appear
assert.ok(result.includes('Line 26'));
assert.ok(result.includes('Line 30'));
assert.ok(!result.includes('Line 1'));
});
it('includes token count in done footer', () => {
const state = makeState({ status: 'done', tokenCount: 12400 });
const result = format(state);
assert.ok(result.includes('12.4k'));
});
it('no token count in footer when zero', () => {
const state = makeState({ status: 'done', tokenCount: 0 });
const result = format(state);
// Should not include "tokens" for zero count
assert.ok(!result.includes('tokens'));
});
it('renders nested child sessions', () => {
const child = makeState({
sessionKey: 'agent:main:subagent:uuid-1',
agentId: 'proj035-planner',
depth: 1,
status: 'done',
lines: ['Reading protocol...'],
});
const parent = makeState({
lines: ['Starting plan...'],
children: [child],
});
const result = format(parent);
assert.ok(result.includes('proj035-planner'));
assert.ok(result.includes('Reading protocol...'));
// 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'));
assert.ok(childLine && /^> {2}/.test(childLine));
});
it('active session has no done footer', () => {
const state = makeState({ status: 'active' });
const result = format(state);
const lines = result.split('\n');
// No line should contain [DONE], [ERROR], [INTERRUPTED]
assert.ok(!lines.some((l) => /\[(DONE|ERROR|INTERRUPTED)\]/.test(l)));
});
});
describe('formatElapsed()', () => {
it('formats seconds', () => {
assert.equal(formatElapsed(0), '0s');
assert.equal(formatElapsed(1000), '1s');
assert.equal(formatElapsed(59000), '59s');
});
it('formats minutes', () => {
assert.equal(formatElapsed(60000), '1m0s');
assert.equal(formatElapsed(90000), '1m30s');
assert.equal(formatElapsed(3599000), '59m59s');
});
it('formats hours', () => {
assert.equal(formatElapsed(3600000), '1h0m');
assert.equal(formatElapsed(7260000), '2h1m');
});
it('handles negative values', () => {
assert.equal(formatElapsed(-1000), '0s');
});
});
describe('formatTokens()', () => {
it('formats small counts', () => {
assert.equal(formatTokens(0), '0');
assert.equal(formatTokens(999), '999');
});
it('formats thousands', () => {
assert.equal(formatTokens(1000), '1.0k');
assert.equal(formatTokens(12400), '12.4k');
assert.equal(formatTokens(999900), '999.9k');
});
it('formats millions', () => {
assert.equal(formatTokens(1000000), '1.0M');
assert.equal(formatTokens(2500000), '2.5M');
});
});
describe('statusIcon()', () => {
it('returns correct icons', () => {
assert.equal(statusIcon('active'), '[ACTIVE]');
assert.equal(statusIcon('done'), '[DONE]');
assert.equal(statusIcon('error'), '[ERROR]');
assert.equal(statusIcon('interrupted'), '[INTERRUPTED]');
assert.equal(statusIcon('unknown'), '[UNKNOWN]');
assert.equal(statusIcon(''), '[UNKNOWN]');
});
});
describe('truncateLine()', () => {
it('does not truncate short lines', () => {
const line = 'Short line';
assert.equal(truncateLine(line), line);
});
it('truncates long lines', () => {
const line = 'x'.repeat(200);
const result = truncateLine(line);
assert.ok(result.length <= 120);
assert.ok(result.endsWith('...'));
});
});
describe('extractAgentId()', () => {
it('extracts agent ID from session key', () => {
assert.equal(extractAgentId('agent:main:mattermost:channel:abc'), 'main');
assert.equal(extractAgentId('agent:coder-agent:session:123'), 'coder-agent');
});
it('handles non-standard keys', () => {
assert.equal(extractAgentId('main'), 'main');
assert.equal(extractAgentId(''), 'unknown');
});
it('handles null/undefined', () => {
assert.equal(extractAgentId(null), 'unknown');
assert.equal(extractAgentId(undefined), 'unknown');
});
});
});

View File

@@ -0,0 +1,185 @@
'use strict';
/**
* Unit tests for tool-labels.js
*/
const { describe, it, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const fs = require('fs');
const os = require('os');
const { loadLabels, resolve, resetLabels } = require('../../src/tool-labels');
describe('tool-labels.js', () => {
beforeEach(() => {
resetLabels();
});
afterEach(() => {
resetLabels();
});
describe('exact match', () => {
it('resolves known tools by exact name', () => {
loadLabels(null);
assert.equal(resolve('exec'), 'Running command...');
assert.equal(resolve('Read'), 'Reading file...');
assert.equal(resolve('Write'), 'Writing file...');
assert.equal(resolve('Edit'), 'Editing file...');
assert.equal(resolve('web_search'), 'Searching the web...');
assert.equal(resolve('web_fetch'), 'Fetching URL...');
assert.equal(resolve('message'), 'Sending message...');
assert.equal(resolve('tts'), 'Generating speech...');
assert.equal(resolve('subagents'), 'Managing sub-agents...');
assert.equal(resolve('image'), 'Analyzing image...');
assert.equal(resolve('process'), 'Managing process...');
assert.equal(resolve('browser'), 'Controlling browser...');
});
});
describe('prefix match', () => {
it('resolves camofox_ tools via prefix', () => {
loadLabels(null);
assert.equal(resolve('camofox_create_tab'), 'Opening browser tab...'); // exact takes priority
assert.equal(resolve('camofox_some_new_tool'), 'Using browser...');
});
it('resolves claude_code_ tools via prefix', () => {
loadLabels(null);
assert.equal(resolve('claude_code_start'), 'Starting Claude Code task...'); // exact takes priority
assert.equal(resolve('claude_code_something_new'), 'Running Claude Code...');
});
});
describe('default label', () => {
it('returns default for unknown tools', () => {
loadLabels(null);
assert.equal(resolve('some_unknown_tool'), 'Working...');
assert.equal(resolve(''), 'Working...');
assert.equal(resolve('xyz'), 'Working...');
});
});
describe('external override', () => {
let tmpFile;
beforeEach(() => {
tmpFile = path.join(os.tmpdir(), `tool-labels-test-${Date.now()}.json`);
});
afterEach(() => {
try {
fs.unlinkSync(tmpFile);
} catch (_e) {
/* ignore */
}
});
it('external exact overrides built-in', () => {
fs.writeFileSync(
tmpFile,
JSON.stringify({
exact: { exec: 'Custom exec label...' },
prefix: {},
}),
);
loadLabels(tmpFile);
assert.equal(resolve('exec'), 'Custom exec label...');
// Non-overridden built-in still works
assert.equal(resolve('Read'), 'Reading file...');
});
it('external prefix adds new prefix', () => {
fs.writeFileSync(
tmpFile,
JSON.stringify({
exact: {},
prefix: { my_tool_: 'My custom tool...' },
}),
);
loadLabels(tmpFile);
assert.equal(resolve('my_tool_do_something'), 'My custom tool...');
});
it('external default overrides built-in default', () => {
fs.writeFileSync(
tmpFile,
JSON.stringify({
exact: {},
prefix: {},
default: 'Custom default...',
}),
);
loadLabels(tmpFile);
assert.equal(resolve('completely_unknown'), 'Custom default...');
});
it('handles missing external file gracefully', () => {
loadLabels('/nonexistent/path/tool-labels.json');
// Should fall back to built-in
assert.equal(resolve('exec'), 'Running command...');
});
it('handles malformed external JSON gracefully', () => {
fs.writeFileSync(tmpFile, 'not valid json {{{');
loadLabels(tmpFile);
// Should fall back to built-in
assert.equal(resolve('exec'), 'Running command...');
});
});
describe('regex match', () => {
let tmpFile;
beforeEach(() => {
tmpFile = path.join(os.tmpdir(), `tool-labels-regex-${Date.now()}.json`);
});
afterEach(() => {
try {
fs.unlinkSync(tmpFile);
} catch (_e) {
/* ignore */
}
});
it('resolves via regex pattern', () => {
fs.writeFileSync(
tmpFile,
JSON.stringify({
exact: {},
prefix: {},
regex: [{ pattern: '/^my_api_/', label: 'Calling API...' }],
}),
);
loadLabels(tmpFile);
assert.equal(resolve('my_api_create'), 'Calling API...');
assert.equal(resolve('my_api_update'), 'Calling API...');
assert.equal(resolve('other_tool'), 'Working...');
});
it('handles invalid regex gracefully', () => {
fs.writeFileSync(
tmpFile,
JSON.stringify({
exact: {},
prefix: {},
regex: [{ pattern: '/[invalid(/', label: 'oops' }],
}),
);
loadLabels(tmpFile);
// Invalid regex skipped — returns default
assert.equal(resolve('anything'), 'Working...');
});
});
describe('auto-load', () => {
it('auto-loads built-in labels on first resolve call', () => {
// resetLabels was called in beforeEach — no explicit loadLabels call
const label = resolve('exec');
assert.equal(label, 'Running command...');
});
});
});