Compare commits
49 Commits
policies/a
...
v5.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4a66f9644 | ||
|
|
07c6fd701b | ||
|
|
5c4a665fb9 | ||
|
|
f1d3ae9c4c | ||
|
|
3fbd46c2d2 | ||
|
|
e483b0bc42 | ||
|
|
888e8af784 | ||
|
|
897abf0a9a | ||
|
|
0b39b39f3b | ||
|
|
cc65f9b5ce | ||
|
|
cdef7a1903 | ||
|
|
0bdfaaa01d | ||
|
|
b0cb6db2a3 | ||
|
|
f545cb00be | ||
|
|
3842adf562 | ||
|
|
9a50bb9d55 | ||
|
|
b320bcf843 | ||
|
|
b7c5124081 | ||
|
|
3e93e40109 | ||
|
|
2d493d5c34 | ||
|
|
79d5e82803 | ||
|
|
9ec52a418d | ||
|
|
f0a51ce411 | ||
|
|
c36a048dbb | ||
|
|
4d644e7a43 | ||
|
|
09441b34c1 | ||
|
|
0d0e6e9d90 | ||
|
|
7aebebf193 | ||
|
|
42755e73ad | ||
|
|
c724e57276 | ||
|
|
868574d939 | ||
|
|
cc485f0009 | ||
|
|
d5989cfab8 | ||
|
|
b255283724 | ||
|
|
bbafdaf2d8 | ||
|
|
3a8532bb30 | ||
|
|
6d31d77567 | ||
|
|
b5bde4ec20 | ||
|
|
7c6c8a4432 | ||
|
|
387998812c | ||
|
|
835faa0eab | ||
|
|
5bb36150c4 | ||
|
|
6df3278e91 | ||
|
|
e3bd6c52dd | ||
|
|
43cfebee96 | ||
|
|
b3ec2c61db | ||
|
|
6ef50269b5 | ||
|
|
fe81de308f | ||
| 0480180b03 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
||||
node_modules/
|
||||
coverage/
|
||||
*.log
|
||||
plugin/server/dist/
|
||||
plugin/webapp/node_modules/
|
||||
.env.daemon
|
||||
|
||||
@@ -2,3 +2,4 @@ node_modules/
|
||||
coverage/
|
||||
dist/
|
||||
package-lock.json
|
||||
Makefile
|
||||
|
||||
16
Makefile
16
Makefile
@@ -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
404
PLAN.md
Normal 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
397
README.md
@@ -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
24
STATE.json
Normal 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
53
deploy-to-agents.sh
Executable 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
36
deploy/Dockerfile
Normal 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"]
|
||||
36
deploy/status-watcher.service
Normal file
36
deploy/status-watcher.service
Normal 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
107
discoveries/README.md
Normal 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.
|
||||
129
docs/v1-removal-checklist.md
Normal file
129
docs/v1-removal-checklist.md
Normal 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").
|
||||
52
hooks/status-watcher-hook/HOOK.md
Normal file
52
hooks/status-watcher-hook/HOOK.md
Normal 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.
|
||||
156
hooks/status-watcher-hook/handler.js
Normal file
156
hooks/status-watcher-hook/handler.js
Normal 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;
|
||||
134
install.sh
134
install.sh
@@ -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
151
package-lock.json
generated
@@ -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",
|
||||
|
||||
17
package.json
17
package.json
@@ -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
101
plugin/Makefile
Normal 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
3
plugin/assets/icon.svg
Normal 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
44
plugin/plugin.json
Normal 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
401
plugin/server/api.go
Normal 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)
|
||||
}
|
||||
71
plugin/server/configuration.go
Normal file
71
plugin/server/configuration.go
Normal 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
46
plugin/server/go.mod
Normal 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
288
plugin/server/go.sum
Normal 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
9
plugin/server/main.go
Normal 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
115
plugin/server/plugin.go
Normal 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
|
||||
}
|
||||
3
plugin/server/public/icon.svg
Normal file
3
plugin/server/public/icon.svg
Normal 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
178
plugin/server/store.go
Normal 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
|
||||
}
|
||||
38
plugin/server/websocket.go
Normal file
38
plugin/server/websocket.go
Normal 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
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
1934
plugin/webapp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
plugin/webapp/package.json
Normal file
20
plugin/webapp/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
197
plugin/webapp/src/components/floating_widget.tsx
Normal file
197
plugin/webapp/src/components/floating_widget.tsx
Normal 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;
|
||||
180
plugin/webapp/src/components/live_status_post.tsx
Normal file
180
plugin/webapp/src/components/live_status_post.tsx
Normal 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;
|
||||
215
plugin/webapp/src/components/rhs_panel.tsx
Normal file
215
plugin/webapp/src/components/rhs_panel.tsx
Normal 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;
|
||||
41
plugin/webapp/src/components/status_line.tsx
Normal file
41
plugin/webapp/src/components/status_line.tsx
Normal 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;
|
||||
46
plugin/webapp/src/components/terminal_view.tsx
Normal file
46
plugin/webapp/src/components/terminal_view.tsx
Normal 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
111
plugin/webapp/src/index.tsx
Normal 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());
|
||||
445
plugin/webapp/src/styles/live_status.css
Normal file
445
plugin/webapp/src/styles/live_status.css
Normal 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;
|
||||
}
|
||||
59
plugin/webapp/src/types.ts
Normal file
59
plugin/webapp/src/types.ts
Normal 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;
|
||||
}
|
||||
18
plugin/webapp/tsconfig.json
Normal file
18
plugin/webapp/tsconfig.json
Normal 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"]
|
||||
}
|
||||
32
plugin/webapp/webpack.config.js
Normal file
32
plugin/webapp/webpack.config.js
Normal 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',
|
||||
},
|
||||
};
|
||||
@@ -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
143
src/circuit-breaker.js
Normal 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
122
src/config.js
Normal 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
118
src/health.js
Normal 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 };
|
||||
@@ -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
43
src/logger.js
Normal 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
171
src/plugin-client.js
Normal 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
574
src/session-monitor.js
Normal 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
345
src/status-box.js
Normal 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
173
src/status-formatter.js
Normal 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
692
src/status-watcher.js
Normal 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
109
src/tool-labels.js
Normal 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
41
src/tool-labels.json
Normal 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
840
src/watcher-manager.js
Normal 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
78
start-daemon.sh
Executable file
@@ -0,0 +1,78 @@
|
||||
#!/bin/bash
|
||||
# start-daemon.sh — Start the live-status watcher daemon with proper config.
|
||||
# This script is the canonical way to start the daemon. It loads env vars,
|
||||
# ensures only one instance runs, and redirects logs properly.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
LOG_FILE="${LIVESTATUS_LOG_FILE:-/tmp/status-watcher.log}"
|
||||
PID_FILE="${PID_FILE:-/tmp/status-watcher.pid}"
|
||||
ENV_FILE="${SCRIPT_DIR}/.env.daemon"
|
||||
|
||||
# Load .env.daemon if it exists
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# Required env vars with defaults
|
||||
export MM_BOT_TOKEN="${MM_BOT_TOKEN:?MM_BOT_TOKEN is required}"
|
||||
export MM_BASE_URL="${MM_BASE_URL:-https://slack.solio.tech}"
|
||||
export MM_BOT_USER_ID="${MM_BOT_USER_ID:-eqtkymoej7rw7dp8xbh7hywzrr}"
|
||||
export TRANSCRIPT_DIR="${TRANSCRIPT_DIR:-/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
|
||||
77
test/integration/poll-fallback.test.js
Normal file
77
test/integration/poll-fallback.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
262
test/integration/session-monitor.test.js
Normal file
262
test/integration/session-monitor.test.js
Normal 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));
|
||||
});
|
||||
});
|
||||
});
|
||||
269
test/integration/status-watcher.test.js
Normal file
269
test/integration/status-watcher.test.js
Normal 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')));
|
||||
});
|
||||
});
|
||||
});
|
||||
369
test/integration/sub-agent.test.js
Normal file
369
test/integration/sub-agent.test.js
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
171
test/unit/circuit-breaker.test.js
Normal file
171
test/unit/circuit-breaker.test.js
Normal 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
135
test/unit/config.test.js
Normal 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
57
test/unit/logger.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
208
test/unit/status-formatter.test.js
Normal file
208
test/unit/status-formatter.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
185
test/unit/tool-labels.test.js
Normal file
185
test/unit/tool-labels.test.js
Normal 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...');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user