feat: Phase 0+1 — repo sync, pino, lint fixes, core components

Phase 0:
- Synced latest live-status.js from workspace (9928 bytes)
- Fixed 43 lint issues: empty catch blocks, console statements
- Added pino dependency
- Created src/tool-labels.json with all known tool mappings
- make check passes

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

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

All 59 unit tests pass. make check clean.
This commit is contained in:
sol
2026-03-07 17:26:53 +00:00
parent b3ec2c61db
commit 43cfebee96
21 changed files with 2691 additions and 287 deletions

View File

@@ -1,4 +1,5 @@
export NODE_ENV := development 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 lint fmt fmt-check secret-scan
@@ -8,16 +9,16 @@ install:
npm install npm install
test: test:
@echo "[SKIP] No tests found" node --test test/unit/*.test.js
lint: lint:
npx eslint . eslint .
fmt: fmt:
npx prettier --write . prettier --write .
fmt-check: fmt-check:
npx prettier --check . prettier --check .
secret-scan: secret-scan:
bash tools/secret-scan.sh . bash tools/secret-scan.sh .

140
PLAN.md
View File

@@ -1,4 +1,5 @@
# Implementation Plan: Live Status v4 (Production-Grade) # Implementation Plan: Live Status v4 (Production-Grade)
> Generated: 2026-03-07 | Agent: planner:proj035-v2 | Status: DRAFT > Generated: 2026-03-07 | Agent: planner:proj035-v2 | Status: DRAFT
> Revised: Incorporates production-grade changes from scalability/efficiency review (comment #11402) > Revised: Incorporates production-grade changes from scalability/efficiency review (comment #11402)
@@ -65,16 +66,16 @@ OpenClaw Gateway
## 3. Tech Stack ## 3. Tech Stack
| Layer | Technology | Version | Reason | | Layer | Technology | Version | Reason |
|-------|-----------|---------|--------| | ------------------ | ------------------ | ------------- | ------------------------------------------------------- |
| Runtime | Node.js | 22.x (system) | Already installed; inotify recursive fs.watch supported | | 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 | | 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 | | Session discovery | setInterval poll | built-in | sessions.json polling for new session detection |
| HTTP client | http.Agent | built-in | keepAlive, maxSockets; no extra dependency | | HTTP client | http.Agent | built-in | keepAlive, maxSockets; no extra dependency |
| Structured logging | pino | ^9.x | Fast JSON logging; single new dependency | | Structured logging | pino | ^9.x | Fast JSON logging; single new dependency |
| Config | process.env | built-in | 12-factor; validated at startup | | Config | process.env | built-in | 12-factor; validated at startup |
| Health check | http.createServer | built-in | Lightweight health endpoint | | Health check | http.createServer | built-in | Lightweight health endpoint |
| Process management | PID file + signals | built-in | Simple, no supervisor dependency | | Process management | PID file + signals | built-in | Simple, no supervisor dependency |
**New npm dependencies:** `pino` only. Everything else uses Node.js built-ins. **New npm dependencies:** `pino` only. Everything else uses Node.js built-ins.
@@ -124,17 +125,18 @@ MATTERMOST_OPENCLAW_LIVESTATUS/
## 5. Dependencies ## 5. Dependencies
| Package | Version | Purpose | New/Existing | | Package | Version | Purpose | New/Existing |
|---------|---------|---------|-------------| | ----------------------------- | -------- | ----------------------- | ----------------- |
| pino | ^9.x | Structured JSON logging | NEW | | pino | ^9.x | Structured JSON logging | NEW |
| node.js | 22.x | Runtime | Existing (system) | | node.js | 22.x | Runtime | Existing (system) |
| http, fs, path, child_process | built-in | All other functionality | Existing | | http, fs, path, child_process | built-in | All other functionality | Existing |
One new npm dependency only. Minimal footprint. One new npm dependency only. Minimal footprint.
## 6. Data Model ## 6. Data Model
### sessions.json entry (relevant fields) ### sessions.json entry (relevant fields)
```json ```json
{ {
"agent:main:subagent:uuid": { "agent:main:subagent:uuid": {
@@ -150,6 +152,7 @@ One new npm dependency only. Minimal footprint.
``` ```
### JSONL event schema ### JSONL event schema
``` ```
type=session -> id (UUID), version (3), cwd — first line only type=session -> id (UUID), version (3), cwd — first line only
type=message -> role=user|assistant|toolResult; content[]=text|toolCall|toolResult|thinking type=message -> role=user|assistant|toolResult; content[]=text|toolCall|toolResult|thinking
@@ -158,6 +161,7 @@ type=model_change -> provider, modelId
``` ```
### SessionState (in-memory per active session) ### SessionState (in-memory per active session)
```json ```json
{ {
"sessionKey": "agent:main:subagent:uuid", "sessionKey": "agent:main:subagent:uuid",
@@ -177,6 +181,7 @@ type=model_change -> provider, modelId
``` ```
### Configuration (env vars) ### Configuration (env vars)
``` ```
MM_TOKEN (required) Mattermost bot token MM_TOKEN (required) Mattermost bot token
MM_URL (required) Mattermost base URL MM_URL (required) Mattermost base URL
@@ -198,6 +203,7 @@ DEFAULT_CHANNEL null Fallback channel for non-MM sessions (null = sk
``` ```
### Status box format (rendered Mattermost text) ### Status box format (rendered Mattermost text)
``` ```
[ACTIVE] main | 38s [ACTIVE] main | 38s
Reading live-status source code... Reading live-status source code...
@@ -216,7 +222,9 @@ Plan ready. Awaiting approval.
## 7. Task Checklist ## 7. Task Checklist
### Phase 0: Repo Sync + Environment Verification ⏱️ 30min ### Phase 0: Repo Sync + Environment Verification ⏱️ 30min
> Parallelizable: no | Dependencies: none > Parallelizable: no | Dependencies: none
- [ ] 0.1: Sync workspace live-status.js (283-line v2) to remote repo — git push → remote matches workspace copy - [ ] 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.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.3: Run `make check` — verify all Makefile targets pass (lint/fmt/test/secret-scan) → clean run, zero failures
@@ -225,6 +233,7 @@ Plan ready. Awaiting approval.
- [ ] 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) - [ ] 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 ### Phase 1: Core Components ⏱️ 8-12h
> Parallelizable: partially (config/logger/circuit-breaker are independent) | Dependencies: Phase 0 > 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.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
@@ -240,7 +249,7 @@ Plan ready. Awaiting approval.
- Circuit breaker wrapping all API calls - Circuit breaker wrapping all API calls
- Retry with exponential backoff on 429/5xx (up to 3 retries) - Retry with exponential backoff on 429/5xx (up to 3 retries)
- Structured logs for every API call - Structured logs for every API call
→ unit tested with mock HTTP server → 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.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.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: - [ ] 1.8: Create `src/status-watcher.js` — core JSONL watcher:
@@ -255,10 +264,11 @@ Plan ready. Awaiting approval.
- Detect file truncation (stat.size < bytesRead) -> reset offset, log warning - Detect file truncation (stat.size < bytesRead) -> reset offset, log warning
- Debounce updates via status-box.js throttle - Debounce updates via status-box.js throttle
- Idle detection: when pendingToolCalls==0 and no new lines for IDLE_TIMEOUT_S - Idle detection: when pendingToolCalls==0 and no new lines for IDLE_TIMEOUT_S
→ integration tested with real JSONL sample files → 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 - [ ] 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 ### Phase 2: Session Monitor + Lifecycle ⏱️ 4-6h
> Parallelizable: no | Dependencies: Phase 1 > Parallelizable: no | Dependencies: Phase 1
- [ ] 2.1: Create `src/session-monitor.js` — polls sessions.json every 2s: - [ ] 2.1: Create `src/session-monitor.js` — polls sessions.json every 2s:
@@ -268,7 +278,7 @@ Plan ready. Awaiting approval.
- Resolves channelId from session key (format: `agent:main:mattermost:channel:{id}:...`) - Resolves channelId from session key (format: `agent:main:mattermost:channel:{id}:...`)
- Resolves rootPostId from session key (format: `...thread:{id}`) - Resolves rootPostId from session key (format: `...thread:{id}`)
- Falls back to DEFAULT_CHANNEL for non-MM sessions (or null to skip) - Falls back to DEFAULT_CHANNEL for non-MM sessions (or null to skip)
→ integration tested with mock sessions.json writes → 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.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.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: - [ ] 2.4: Create `src/watcher-manager.js` — top-level orchestrator:
@@ -280,10 +290,11 @@ Plan ready. Awaiting approval.
- Registers SIGTERM/SIGINT handlers: - Registers SIGTERM/SIGINT handlers:
- On signal: mark all active status boxes "interrupted", flush all pending updates, remove PID file, exit 0 - 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 - CLI: `node watcher-manager.js start|stop|status` → process management
→ smoke tested end-to-end → smoke tested end-to-end
- [ ] 2.5: Integration test suite (`test/integration/`) — lifecycle events, restart recovery → `npm run test:integration` green - [ ] 2.5: Integration test suite (`test/integration/`) — lifecycle events, restart recovery → `npm run test:integration` green
### Phase 3: Sub-Agent Support ⏱️ 3-4h ### Phase 3: Sub-Agent Support ⏱️ 3-4h
> Parallelizable: no | Dependencies: Phase 2 > 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.1: Sub-agent detection — session-monitor detects entries with `spawnedBy` field; links child SessionState to parent via `parentSessionKey` → linked correctly
@@ -293,6 +304,7 @@ Plan ready. Awaiting approval.
- [ ] 3.5: Integration test — spawn mock sub-agent transcript, verify parent status box shows nested child progress → manual verification in Mattermost - [ ] 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 ### Phase 4: Hook Integration ⏱️ 1h
> Parallelizable: no | Dependencies: Phase 2 (watcher-manager CLI working) > 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.1: Create `hooks/status-watcher-hook/HOOK.md` — events: ["gateway:startup"], description, required env vars listed → OpenClaw discovers hook
@@ -301,6 +313,7 @@ Plan ready. Awaiting approval.
- [ ] 4.4: Test: gateway restart -> watcher starts, PID file written, health endpoint responds → verified - [ ] 4.4: Test: gateway restart -> watcher starts, PID file written, health endpoint responds → verified
### Phase 5: Polish + Deployment ⏱️ 3-4h ### Phase 5: Polish + Deployment ⏱️ 3-4h
> Parallelizable: yes (docs, deploy scripts, skill rewrite are independent) | Dependencies: Phase 4 > 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.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
@@ -314,6 +327,7 @@ Plan ready. Awaiting approval.
- [ ] 5.9: Run `make check` → zero lint/format errors; `npm test` → green - [ ] 5.9: Run `make check` → zero lint/format errors; `npm test` → green
### Phase 6: Remove v1 Injection from AGENTS.md ⏱️ 30min ### Phase 6: Remove v1 Injection from AGENTS.md ⏱️ 30min
> Parallelizable: no | Dependencies: Phase 5 fully verified + watcher confirmed running > 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 > SAFETY: Do not execute this phase until watcher has been running successfully for at least 1 hour
@@ -324,67 +338,67 @@ Plan ready. Awaiting approval.
## 8. Testing Strategy ## 8. Testing Strategy
| What | Type | How | Success Criteria | | What | Type | How | Success Criteria |
|------|------|-----|-----------------| | ------------------- | ----------- | --------------------------------------------------- | ------------------------------------------------------------- |
| config.js | Unit | Env var injection, missing var detection | Throws on missing required vars; correct defaults | | 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 | | 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 | | 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 | | 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-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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | E2E smoke test | Manual | Real agent task in Mattermost | Real-time updates; no spam; done on completion |
## 9. Risks & Mitigations ## 9. Risks & Mitigations
| Risk | Impact | Mitigation | | 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) | | 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 | | 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 | | 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 | | 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 | | 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 | | 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) | | 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 | | 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 | | 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 | | Daemon crashes mid-session | Medium | Health check via systemd/Docker; restart policy; offset persistence enables recovery |
## 10. Effort Estimate ## 10. Effort Estimate
| Phase | Time | Can Parallelize? | Depends On | | Phase | Time | Can Parallelize? | Depends On |
|-------|------|-------------------|-----------| | -------------------------------------- | ---------- | ----------------------------------------- | ---------------- |
| Phase 0: Repo + Env Verification | 15min | No | — | | Phase 0: Repo + Env Verification | 15min | No | — |
| Phase 1: Core Components | 8-12h | Partially (config/logger/circuit-breaker) | Phase 0 | | Phase 1: Core Components | 8-12h | Partially (config/logger/circuit-breaker) | Phase 0 |
| Phase 2: Session Monitor + Lifecycle | 4-6h | No | Phase 1 | | Phase 2: Session Monitor + Lifecycle | 4-6h | No | Phase 1 |
| Phase 3: Sub-Agent Support | 3-4h | No | Phase 2 | | Phase 3: Sub-Agent Support | 3-4h | No | Phase 2 |
| Phase 4: Hook Integration | 1h | No | Phase 2+3 | | Phase 4: Hook Integration | 1h | No | Phase 2+3 |
| Phase 5: Polish + Deployment | 3-4h | Yes (docs, deploy, skill) | Phase 4 | | Phase 5: Polish + Deployment | 3-4h | Yes (docs, deploy, skill) | Phase 4 |
| Phase 6: Remove v1 AGENTS.md Injection | 30min | No | Phase 5 verified | | Phase 6: Remove v1 AGENTS.md Injection | 30min | No | Phase 5 verified |
| **Total** | **20-28h** | | | | **Total** | **20-28h** | | |
## 11. Open Questions ## 11. Open Questions
All questions have defaults that allow execution to proceed without answers. 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. - [ ] **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. **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. - [ ] **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. **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. - [ ] **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. **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. - [ ] **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. **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). - [ ] **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. **Default if not answered:** Remove from all agents. This is the stated goal — removing v1 injection everywhere.

View File

@@ -9,6 +9,7 @@ Planner sub-agent (proj035-planner) conducted inline discovery before drafting t
**Confirmed format (JSONL, version 3):** **Confirmed format (JSONL, version 3):**
Each line is a JSON object with `type` field: Each line is a JSON object with `type` field:
- `session` — First line only. Contains `id` (UUID), `version: 3`, `cwd` - `session` — First line only. Contains `id` (UUID), `version: 3`, `cwd`
- `model_change``provider`, `modelId` change events - `model_change``provider`, `modelId` change events
- `thinking_level_change` — thinking on/off - `thinking_level_change` — thinking on/off
@@ -16,6 +17,7 @@ Each line is a JSON object with `type` field:
- `message` — Main event type. `role` = `user`, `assistant`, or `toolResult` - `message` — Main event type. `role` = `user`, `assistant`, or `toolResult`
Message content types: Message content types:
- `{type: "text", text: "..."}` — plain text from any role - `{type: "text", text: "..."}` — plain text from any role
- `{type: "toolCall", id, name, arguments: {...}}` — tool invocations in assistant messages - `{type: "toolCall", id, name, arguments: {...}}` — tool invocations in assistant messages
- `{type: "thinking", thinking: "..."}` — internal reasoning (thinking mode) - `{type: "thinking", thinking: "..."}` — internal reasoning (thinking mode)
@@ -25,6 +27,7 @@ Assistant messages carry extra fields: `api`, `provider`, `model`, `usage`, `sto
ToolResult messages carry: `toolCallId`, `toolName`, `isError`, `content: [{type, text}]` ToolResult messages carry: `toolCallId`, `toolName`, `isError`, `content: [{type, text}]`
**Key signals for watcher:** **Key signals for watcher:**
- `stopReason: "stop"` + no new lines → agent turn complete → idle - `stopReason: "stop"` + no new lines → agent turn complete → idle
- `stopReason: "toolUse"` → agent waiting for tool results → NOT idle - `stopReason: "toolUse"` → agent waiting for tool results → NOT idle
- `custom.customType: "openclaw.cache-ttl"` → turn boundary marker - `custom.customType: "openclaw.cache-ttl"` → turn boundary marker
@@ -34,6 +37,7 @@ ToolResult messages carry: `toolCallId`, `toolName`, `isError`, `content: [{type
Session keys in sessions.json follow the pattern: `agent:{agentId}:{context}` Session keys in sessions.json follow the pattern: `agent:{agentId}:{context}`
Examples: Examples:
- `agent:main:main` — direct session - `agent:main:main` — direct session
- `agent:main:mattermost:channel:{channelId}` — channel session - `agent:main:mattermost:channel:{channelId}` — channel session
- `agent:main:mattermost:channel:{channelId}:thread:{threadId}` — thread session - `agent:main:mattermost:channel:{channelId}:thread:{threadId}` — thread session
@@ -42,6 +46,7 @@ Examples:
- `agent:main:cron:{name}` — cron session - `agent:main:cron:{name}` — cron session
Sub-agent entry fields relevant to watcher: Sub-agent entry fields relevant to watcher:
- `sessionId` — maps to `{sessionId}.jsonl` filename - `sessionId` — maps to `{sessionId}.jsonl` filename
- `spawnedBy` — parent session key (for nesting) - `spawnedBy` — parent session key (for nesting)
- `spawnDepth` — nesting depth (1 = direct child of main) - `spawnDepth` — nesting depth (1 = direct child of main)
@@ -49,6 +54,7 @@ Sub-agent entry fields relevant to watcher:
- `channel` — delivery channel (mattermost, etc.) - `channel` — delivery channel (mattermost, etc.)
Sessions files: `/home/node/.openclaw/agents/{agentId}/sessions/` Sessions files: `/home/node/.openclaw/agents/{agentId}/sessions/`
- `sessions.json` — registry (updated on every message) - `sessions.json` — registry (updated on every message)
- `{uuid}.jsonl` — transcript files - `{uuid}.jsonl` — transcript files
- `{uuid}-topic-{topicId}.jsonl` — topic-scoped transcripts - `{uuid}-topic-{topicId}.jsonl` — topic-scoped transcripts
@@ -56,6 +62,7 @@ Sessions files: `/home/node/.openclaw/agents/{agentId}/sessions/`
## Discovery 3: OpenClaw Hook Events ## Discovery 3: OpenClaw Hook Events
Available internal hook events (confirmed from source): Available internal hook events (confirmed from source):
- `command:new`, `command:reset`, `command:stop` — user commands - `command:new`, `command:reset`, `command:stop` — user commands
- `command` — all commands - `command` — all commands
- `agent:bootstrap` — before workspace files injected - `agent:bootstrap` — before workspace files injected

151
package-lock.json generated
View File

@@ -1,15 +1,18 @@
{ {
"name": "mattermost-openclaw-livestatus", "name": "mattermost-openclaw-livestatus",
"version": "1.0.0", "version": "4.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mattermost-openclaw-livestatus", "name": "mattermost-openclaw-livestatus",
"version": "1.0.0", "version": "4.0.0",
"dependencies": {
"pino": "^9.14.0"
},
"devDependencies": { "devDependencies": {
"eslint": "^8.56.0", "eslint": "^8.57.1",
"eslint-plugin-security": "^2.1.0", "eslint-plugin-security": "^2.1.1",
"prettier": "^3.2.0" "prettier": "^3.2.0"
} }
}, },
@@ -152,6 +155,12 @@
"node": ">= 8" "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": { "node_modules/@ungap/structured-clone": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@@ -232,6 +241,15 @@
"dev": true, "dev": true,
"license": "Python-2.0" "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": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -608,9 +626,9 @@
} }
}, },
"node_modules/flatted": { "node_modules/flatted": {
"version": "3.3.3", "version": "3.3.4",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -893,6 +911,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -996,6 +1023,43 @@
"node": ">=8" "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": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -1007,9 +1071,9 @@
} }
}, },
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.8.1", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.0.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "integrity": "sha512-/vBUecTGaPlRVwyZVROVC58bYIScqaoEJzZmzQXXrZOzqn0TwWz0EnOozOlFO/YAImRnb7XsKpTCd3m1SjS2Ww==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@@ -1022,6 +1086,22 @@
"url": "https://github.com/prettier/prettier?sponsor=1" "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": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -1053,6 +1133,21 @@
], ],
"license": "MIT" "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": { "node_modules/regexp-tree": {
"version": "0.1.27", "version": "0.1.27",
"resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz",
@@ -1135,6 +1230,15 @@
"regexp-tree": "~0.1.1" "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": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -1158,6 +1262,24 @@
"node": ">=8" "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": { "node_modules/strip-ansi": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@@ -1204,6 +1326,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@@ -1,11 +1,22 @@
{ {
"name": "mattermost-openclaw-livestatus", "name": "mattermost-openclaw-livestatus",
"version": "1.0.0", "version": "4.0.0",
"private": true, "private": true,
"description": "OpenClaw Live Status Tool for Mattermost", "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": { "devDependencies": {
"eslint": "^8.56.0", "eslint": "^8.57.1",
"eslint-plugin-security": "^2.1.0", "eslint-plugin-security": "^2.1.1",
"prettier": "^3.2.0" "prettier": "^3.2.0"
} }
} }

View File

@@ -6,17 +6,21 @@ Creates a single "status box" post and updates it repeatedly — no chat spam.
## Usage ## Usage
### Create a status box ### Create a status box
```bash ```bash
live-status --channel <CHANNEL_ID> create "🚀 **Task Started:** Initializing..." live-status --channel <CHANNEL_ID> create "🚀 **Task Started:** Initializing..."
``` ```
Returns the `POST_ID` (26-char string). **Capture it.** Returns the `POST_ID` (26-char string). **Capture it.**
### Create in a thread ### Create in a thread
```bash ```bash
live-status --channel <CHANNEL_ID> --reply-to <ROOT_POST_ID> create "🚀 Starting..." live-status --channel <CHANNEL_ID> --reply-to <ROOT_POST_ID> create "🚀 Starting..."
``` ```
### Update the status box ### Update the status box
```bash ```bash
live-status update <POST_ID> "🚀 **Task Running** live-status update <POST_ID> "🚀 **Task Running**
\`\`\` \`\`\`
@@ -26,6 +30,7 @@ live-status update <POST_ID> "🚀 **Task Running**
``` ```
### Mark complete ### Mark complete
```bash ```bash
live-status update <POST_ID> "✅ **Task Complete** live-status update <POST_ID> "✅ **Task Complete**
\`\`\` \`\`\`
@@ -36,6 +41,7 @@ live-status update <POST_ID> "✅ **Task Complete**
``` ```
### Delete a status box ### Delete a status box
```bash ```bash
live-status delete <POST_ID> live-status delete <POST_ID>
``` ```
@@ -43,32 +49,36 @@ live-status delete <POST_ID>
## Multi-Agent Support ## Multi-Agent Support
When multiple agents share a channel, each creates its **own** status box: When multiple agents share a channel, each creates its **own** status box:
```bash ```bash
# Agent A # Agent A
BOX_A=$(live-status --channel $CH --agent god-agent create "🤖 God Agent working...") BOX_A=$(live-status --channel $CH --agent god-agent create "🤖 God Agent working...")
# Agent B # Agent B
BOX_B=$(live-status --channel $CH --agent nutrition-agent create "🥗 Nutrition Agent working...") BOX_B=$(live-status --channel $CH --agent nutrition-agent create "🥗 Nutrition Agent working...")
``` ```
Each agent updates only its own box by ID. No conflicts. Each agent updates only its own box by ID. No conflicts.
## Options ## Options
| Flag | Purpose | | Flag | Purpose |
|---|---| | --------------- | --------------------------------------------------- |
| `--channel ID` | Target channel (or set `MM_CHANNEL_ID`) | | `--channel ID` | Target channel (or set `MM_CHANNEL_ID`) |
| `--reply-to ID` | Post as thread reply (sets `root_id`) | | `--reply-to ID` | Post as thread reply (sets `root_id`) |
| `--agent NAME` | Use bot token mapped to this agent in openclaw.json | | `--agent NAME` | Use bot token mapped to this agent in openclaw.json |
| `--token TOKEN` | Explicit bot token (overrides everything) | | `--token TOKEN` | Explicit bot token (overrides everything) |
| `--host HOST` | Mattermost hostname | | `--host HOST` | Mattermost hostname |
## Auto-Detection ## Auto-Detection
The tool reads `openclaw.json` automatically for: The tool reads `openclaw.json` automatically for:
- **Host** — from `mattermost.baseUrl` - **Host** — from `mattermost.baseUrl`
- **Token** — from `mattermost.accounts` (mapped via `--agent` or defaults) - **Token** — from `mattermost.accounts` (mapped via `--agent` or defaults)
- No env vars or manual config needed in most cases. - No env vars or manual config needed in most cases.
## Protocol ## Protocol
1. **Always** capture the `POST_ID` from `create`. 1. **Always** capture the `POST_ID` from `create`.
2. **Always** append to previous log (maintain full history in the message). 2. **Always** append to previous log (maintain full history in the message).
3. **Use code blocks** for technical logs. 3. **Use code blocks** for technical logs.

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

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

112
src/config.js Normal file
View File

@@ -0,0 +1,112 @@
'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),
},
// Transcript directory (OpenClaw agents)
transcriptDir: getEnv('TRANSCRIPT_DIR', '/home/node/.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),
};
// Validate MM base URL
try {
new URL(config.mm.baseUrl);
} catch (_e) {
throw new Error(`MM_BASE_URL is not a valid URL: ${config.mm.baseUrl}`);
}
return config;
}
// Singleton — built once, exported
let _config = null;
function getConfig() {
if (!_config) {
_config = buildConfig();
}
return _config;
}
// Allow resetting config in tests
function resetConfig() {
_config = null;
}
module.exports = { getConfig, resetConfig, buildConfig };

118
src/health.js Normal file
View File

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

View File

@@ -1,5 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
/* eslint-disable no-console */
const https = require('https'); const https = require('https');
const http = require('http'); const http = require('http');
const fs = require('fs'); const fs = require('fs');
@@ -12,272 +14,315 @@ const options = {};
const otherArgs = []; const otherArgs = [];
for (let i = 0; i < args.length; i++) { for (let i = 0; i < args.length; i++) {
const arg = args[i]; const arg = args[i]; // eslint-disable-line security/detect-object-injection
const next = args[i + 1]; const next = args[i + 1]; // eslint-disable-line security/detect-object-injection
if (arg === '--channel' && next) { options.channel = next; i++; } if (arg === '--channel' && next) {
else if (arg === '--reply-to' && next) { options.replyTo = next; i++; } options.channel = next;
else if (arg === '--agent' && next) { options.agent = next; i++; } i++;
else if (arg === '--token' && next) { options.token = next; i++; } } else if (arg === '--reply-to' && next) {
else if (arg === '--host' && next) { options.host = next; i++; } options.replyTo = next;
else if (arg === '--rich') { options.rich = true; } i++;
else if (!command && ['create', 'update', 'complete', 'error', 'delete'].includes(arg)) { } else if (arg === '--agent' && next) {
command = arg; options.agent = next;
} else { i++;
otherArgs.push(arg); } 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'].includes(arg)) {
command = arg;
} else {
otherArgs.push(arg);
}
} }
// --- LOAD CONFIG --- // --- LOAD CONFIG ---
function loadConfig() { function loadConfig() {
const searchPaths = [ const searchPaths = [
process.env.OPENCLAW_CONFIG_DIR && path.join(process.env.OPENCLAW_CONFIG_DIR, 'openclaw.json'), 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'), process.env.XDG_CONFIG_HOME && path.join(process.env.XDG_CONFIG_HOME, 'openclaw.json'),
path.join(process.env.HOME || '/root', '.openclaw', 'openclaw.json'), path.join(process.env.HOME || '/root', '.openclaw', 'openclaw.json'),
'/home/node/.openclaw/openclaw.json' '/home/node/.openclaw/openclaw.json',
].filter(Boolean); ].filter(Boolean);
for (const p of searchPaths) { for (const p of searchPaths) {
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } try {
catch (_) {} // 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; }
return null;
} }
function resolveToken(config) { function resolveToken(config) {
if (options.token) return options.token; if (options.token) return options.token;
if (process.env.MM_BOT_TOKEN) return process.env.MM_BOT_TOKEN; if (process.env.MM_BOT_TOKEN) return process.env.MM_BOT_TOKEN;
if (!config) return null; if (!config) return null;
const mm = config.mattermost || (config.channels && config.channels.mattermost) || {}; const mm = config.mattermost || (config.channels && config.channels.mattermost) || {};
const accounts = mm.accounts || {}; const accounts = mm.accounts || {};
if (options.agent) { if (options.agent) {
try { try {
const mapPath = path.join(__dirname, 'agent-accounts.json'); const mapPath = path.join(__dirname, 'agent-accounts.json');
const agentMap = JSON.parse(fs.readFileSync(mapPath, 'utf8')); const agentMap = JSON.parse(fs.readFileSync(mapPath, 'utf8'));
const accName = agentMap[options.agent]; // eslint-disable-next-line security/detect-object-injection
if (accName && accounts[accName] && accounts[accName].botToken) { const accName = agentMap[options.agent];
return accounts[accName].botToken; // eslint-disable-next-line security/detect-object-injection
} if (accName && accounts[accName] && accounts[accName].botToken) {
} catch (_) {} // 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; if (accounts.default && accounts.default.botToken) return accounts.default.botToken;
for (const acc of Object.values(accounts)) { for (const acc of Object.values(accounts)) {
if (acc.botToken) return acc.botToken; if (acc.botToken) return acc.botToken;
} }
return null; return null;
} }
function resolveHost(config) { function resolveHost(config) {
if (options.host) return options.host; if (options.host) return options.host;
if (process.env.MM_HOST) return process.env.MM_HOST; if (process.env.MM_HOST) return process.env.MM_HOST;
if (config) { if (config) {
const mm = config.mattermost || (config.channels && config.channels.mattermost) || {}; const mm = config.mattermost || (config.channels && config.channels.mattermost) || {};
const baseUrl = mm.baseUrl || ''; const baseUrl = mm.baseUrl || '';
if (baseUrl) { try { return new URL(baseUrl).hostname; } catch (_) {} } if (baseUrl) {
try {
return new URL(baseUrl).hostname;
} catch (_e) {
/* invalid URL */
}
} }
return 'localhost'; }
return 'localhost';
} }
function resolvePort(config) { function resolvePort(config) {
if (process.env.MM_PORT) return parseInt(process.env.MM_PORT, 10); if (process.env.MM_PORT) return parseInt(process.env.MM_PORT, 10);
if (config) { if (config) {
const mm = config.mattermost || (config.channels && config.channels.mattermost) || {}; const mm = config.mattermost || (config.channels && config.channels.mattermost) || {};
const baseUrl = mm.baseUrl || ''; const baseUrl = mm.baseUrl || '';
if (baseUrl) { if (baseUrl) {
try { try {
const url = new URL(baseUrl); const url = new URL(baseUrl);
return url.port ? parseInt(url.port, 10) : (url.protocol === 'https:' ? 443 : 80); return url.port ? parseInt(url.port, 10) : url.protocol === 'https:' ? 443 : 80;
} catch (_) {} } catch (_e) {
} /* invalid URL — use default port */
}
} }
return 443; }
return 443;
} }
function resolveProtocol(config) { function resolveProtocol(config) {
if (config) { if (config) {
const mm = config.mattermost || (config.channels && config.channels.mattermost) || {}; const mm = config.mattermost || (config.channels && config.channels.mattermost) || {};
if ((mm.baseUrl || '').startsWith('http://')) return 'http'; if ((mm.baseUrl || '').startsWith('http://')) return 'http';
} }
return 'https'; return 'https';
} }
// --- BUILD CONFIG --- // --- BUILD CONFIG ---
const ocConfig = loadConfig(); const ocConfig = loadConfig();
const CONFIG = { const CONFIG = {
host: resolveHost(ocConfig), host: resolveHost(ocConfig),
port: resolvePort(ocConfig), port: resolvePort(ocConfig),
protocol: resolveProtocol(ocConfig), protocol: resolveProtocol(ocConfig),
token: resolveToken(ocConfig), token: resolveToken(ocConfig),
channel_id: options.channel || process.env.MM_CHANNEL_ID || process.env.CHANNEL_ID channel_id: options.channel || process.env.MM_CHANNEL_ID || process.env.CHANNEL_ID,
}; };
if (!CONFIG.token) { if (!CONFIG.token) {
console.error('Error: No bot token found.'); console.error('Error: No bot token found.');
console.error(' Set MM_BOT_TOKEN, use --token, or configure openclaw.json'); console.error(' Set MM_BOT_TOKEN, use --token, or configure openclaw.json');
process.exit(1); process.exit(1);
} }
// --- HTTP REQUEST --- // --- HTTP REQUEST ---
function request(method, apiPath, data) { function request(method, apiPath, data) {
const transport = CONFIG.protocol === 'https' ? https : http; const transport = CONFIG.protocol === 'https' ? https : http;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const req = transport.request({ const req = transport.request(
hostname: CONFIG.host, port: CONFIG.port, {
path: '/api/v4' + apiPath, method, hostname: CONFIG.host,
headers: { 'Authorization': `Bearer ${CONFIG.token}`, 'Content-Type': 'application/json' } port: CONFIG.port,
}, (res) => { path: '/api/v4' + apiPath,
let body = ''; method,
res.on('data', (chunk) => body += chunk); headers: { Authorization: `Bearer ${CONFIG.token}`, 'Content-Type': 'application/json' },
res.on('end', () => { },
if (res.statusCode >= 200 && res.statusCode < 300) { (res) => {
try { resolve(JSON.parse(body)); } catch (_) { resolve(body); } let body = '';
} else { res.on('data', (chunk) => (body += chunk));
let msg = `HTTP ${res.statusCode}`; res.on('end', () => {
try { msg = JSON.parse(body).message || msg; } catch (_) {} if (res.statusCode >= 200 && res.statusCode < 300) {
reject(new Error(msg)); 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));
}
}); });
req.on('error', (e) => reject(e)); },
if (data) req.write(JSON.stringify(data)); );
req.end(); req.on('error', (e) => reject(e));
}); if (data) req.write(JSON.stringify(data));
req.end();
});
} }
// --- RICH ATTACHMENT HELPERS --- // --- RICH ATTACHMENT HELPERS ---
const RICH_STYLES = { const RICH_STYLES = {
create: { color: '#FFA500', prefix: '⏳', status: '🔄 Running' }, create: { color: '#FFA500', prefix: '⏳', status: '🔄 Running' },
update: { color: '#FFA500', prefix: '🔄', status: '🔄 Running' }, update: { color: '#FFA500', prefix: '🔄', status: '🔄 Running' },
complete: { color: '#36A64F', prefix: '✅', status: '✅ Complete' }, complete: { color: '#36A64F', prefix: '✅', status: '✅ Complete' },
error: { color: '#DC3545', prefix: '❌', status: '❌ Error' } error: { color: '#DC3545', prefix: '❌', status: '❌ Error' },
}; };
function buildAttachment(cmd, text) { function buildAttachment(cmd, text) {
const style = RICH_STYLES[cmd] || RICH_STYLES.update; const style = RICH_STYLES[cmd] || RICH_STYLES.update; // eslint-disable-line security/detect-object-injection
const agentName = options.agent || 'unknown'; const agentName = options.agent || 'unknown';
// Split text: first line = title, rest = log body // Split text: first line = title, rest = log body
const lines = text.split('\n'); const lines = text.split('\n');
const title = `${style.prefix} ${lines[0]}`; const title = `${style.prefix} ${lines[0]}`;
const body = lines.length > 1 ? lines.slice(1).join('\n') : ''; const body = lines.length > 1 ? lines.slice(1).join('\n') : '';
return { return {
color: style.color, color: style.color,
title: title, title: title,
text: body || undefined, text: body || undefined,
fields: [ fields: [
{ short: true, title: 'Agent', value: agentName }, { short: true, title: 'Agent', value: agentName },
{ short: true, title: 'Status', value: style.status } { short: true, title: 'Status', value: style.status },
] ],
}; };
} }
// --- COMMANDS --- // --- COMMANDS ---
async function createPost(text, cmd) { async function createPost(text, cmd) {
if (!CONFIG.channel_id) { if (!CONFIG.channel_id) {
console.error('Error: Channel ID required. Use --channel <id> or set MM_CHANNEL_ID.'); console.error('Error: Channel ID required. Use --channel <id> or set MM_CHANNEL_ID.');
process.exit(1); process.exit(1);
} }
if (!text || !text.trim()) { if (!text || !text.trim()) {
console.error('Error: Message text is required for create.'); console.error('Error: Message text is required for create.');
process.exit(1); process.exit(1);
} }
try { try {
let payload; let payload;
if (options.rich) { if (options.rich) {
payload = { payload = {
channel_id: CONFIG.channel_id, channel_id: CONFIG.channel_id,
message: '', message: '',
props: { attachments: [buildAttachment(cmd || 'create', text)] } props: { attachments: [buildAttachment(cmd || 'create', text)] },
}; };
} else { } else {
payload = { channel_id: CONFIG.channel_id, message: text }; payload = { channel_id: CONFIG.channel_id, message: text };
}
if (options.replyTo) payload.root_id = options.replyTo;
const result = await request('POST', '/posts', payload);
console.log(result.id);
} catch (e) {
console.error('Error (create):', e.message);
process.exit(1);
} }
if (options.replyTo) payload.root_id = options.replyTo;
const result = await request('POST', '/posts', payload);
console.log(result.id);
} catch (e) {
console.error('Error (create):', e.message);
process.exit(1);
}
} }
async function updatePost(postId, text, cmd) { async function updatePost(postId, text, cmd) {
if (!postId) { if (!postId) {
console.error('Error: Post ID is required for update.'); console.error('Error: Post ID is required for update.');
process.exit(1); process.exit(1);
} }
if (!text || !text.trim()) { if (!text || !text.trim()) {
console.error('Error: Message text is required for update.'); console.error('Error: Message text is required for update.');
process.exit(1); process.exit(1);
} }
try { try {
if (options.rich) { if (options.rich) {
await request('PUT', `/posts/${postId}`, { await request('PUT', `/posts/${postId}`, {
id: postId, id: postId,
message: '', message: '',
props: { attachments: [buildAttachment(cmd || 'update', text)] } props: { attachments: [buildAttachment(cmd || 'update', text)] },
}); });
} else { } else {
const current = await request('GET', `/posts/${postId}`); const current = await request('GET', `/posts/${postId}`);
await request('PUT', `/posts/${postId}`, { await request('PUT', `/posts/${postId}`, {
id: postId, message: text, props: current.props id: postId,
}); message: text,
} props: current.props,
console.log('updated'); });
} catch (e) {
console.error('Error (update):', e.message);
process.exit(1);
} }
console.log('updated');
} catch (e) {
console.error('Error (update):', e.message);
process.exit(1);
}
} }
async function deletePost(postId) { async function deletePost(postId) {
if (!postId) { if (!postId) {
console.error('Error: Post ID is required for delete.'); console.error('Error: Post ID is required for delete.');
process.exit(1); process.exit(1);
} }
try { try {
await request('DELETE', `/posts/${postId}`); await request('DELETE', `/posts/${postId}`);
console.log('deleted'); console.log('deleted');
} catch (e) { } catch (e) {
console.error('Error (delete):', e.message); console.error('Error (delete):', e.message);
process.exit(1); process.exit(1);
} }
} }
// --- CLI ROUTER --- // --- CLI ROUTER ---
if (command === 'create') { if (command === 'create') {
createPost(otherArgs.join(' '), 'create'); createPost(otherArgs.join(' '), 'create');
} else if (command === 'update') { } else if (command === 'update') {
updatePost(otherArgs[0], otherArgs.slice(1).join(' '), 'update'); updatePost(otherArgs[0], otherArgs.slice(1).join(' '), 'update');
} else if (command === 'complete') { } else if (command === 'complete') {
updatePost(otherArgs[0], otherArgs.slice(1).join(' '), 'complete'); updatePost(otherArgs[0], otherArgs.slice(1).join(' '), 'complete');
} else if (command === 'error') { } else if (command === 'error') {
updatePost(otherArgs[0], otherArgs.slice(1).join(' '), 'error'); updatePost(otherArgs[0], otherArgs.slice(1).join(' '), 'error');
} else if (command === 'delete') { } else if (command === 'delete') {
deletePost(otherArgs[0]); deletePost(otherArgs[0]);
} else { } else {
console.log('Usage:'); console.log('Usage:');
console.log(' live-status [options] create <text>'); console.log(' live-status [options] create <text>');
console.log(' live-status [options] update <id> <text>'); console.log(' live-status [options] update <id> <text>');
console.log(' live-status [options] complete <id> <text>'); console.log(' live-status [options] complete <id> <text>');
console.log(' live-status [options] error <id> <text>'); console.log(' live-status [options] error <id> <text>');
console.log(' live-status [options] delete <id>'); console.log(' live-status [options] delete <id>');
console.log(''); console.log('');
console.log('Options:'); console.log('Options:');
console.log(' --rich Use rich message attachments (colored cards)'); console.log(' --rich Use rich message attachments (colored cards)');
console.log(' --channel ID Target channel'); console.log(' --channel ID Target channel');
console.log(' --reply-to ID Post as thread reply'); console.log(' --reply-to ID Post as thread reply');
console.log(' --agent NAME Use bot token mapped to this agent'); console.log(' --agent NAME Use bot token mapped to this agent');
console.log(' --token TOKEN Explicit bot token (overrides all)'); console.log(' --token TOKEN Explicit bot token (overrides all)');
console.log(' --host HOST Mattermost hostname'); console.log(' --host HOST Mattermost hostname');
console.log(''); console.log('');
console.log('Rich mode colors:'); console.log('Rich mode colors:');
console.log(' create/update → Orange (running)'); console.log(' create/update → Orange (running)');
console.log(' complete → Green (done)'); console.log(' complete → Green (done)');
console.log(' error → Red (failed)'); console.log(' error → Red (failed)');
process.exit(1); process.exit(1);
} }

43
src/logger.js Normal file
View File

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

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

@@ -0,0 +1,328 @@
'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 { 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 {
/**
* @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) {
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;
}
/**
* 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
*/
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 {
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)));
}
/**
* Delete a post.
* @param {string} postId
* @returns {Promise<void>}
*/
async deletePost(postId) {
await this._apiCall('DELETE', `/api/v4/posts/${postId}`, null);
}
/**
* Truncate text to maxMessageChars.
* @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.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 };

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

@@ -0,0 +1,142 @@
'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} ${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}`);
}
return lines.join('\n');
}
/**
* 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]';
}
}
/**
* 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 };

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

@@ -0,0 +1,394 @@
'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');
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;
const state = {
sessionKey,
transcriptFile,
status: 'active',
startTime: initialState.startTime || Date.now(),
lines: initialState.lines || [],
pendingToolCalls: 0,
lastOffset: initialState.lastOffset || 0,
lastActivityAt: Date.now(),
agentId: initialState.agentId || extractAgentId(sessionKey),
depth: initialState.depth || 0,
tokenCount: 0,
children: [],
idleTimer: null,
};
this.sessions.set(sessionKey, state);
this.fileToSession.set(transcriptFile, sessionKey);
if (this.logger) {
this.logger.debug({ sessionKey, transcriptFile }, 'Session added to watcher');
}
// Immediately read any existing content
this._readFile(sessionKey, state);
}
/**
* 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);
this.fileToSession.delete(state.transcriptFile);
this.sessions.delete(sessionKey);
if (this.logger) {
this.logger.debug({ sessionKey }, 'Session removed from watcher');
}
}
/**
* 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 idle timers
for (const [, state] of this.sessions) {
if (state.idleTimer) {
clearTimeout(state.idleTimer);
state.idleTimer = null;
}
}
if (this.logger) this.logger.info('StatusWatcher stopped');
}
/**
* Handle a file change event.
* @private
*/
_onFileChange(fullPath) {
// Only process .jsonl files
if (!fullPath.endsWith('.jsonl')) return;
const sessionKey = this.fileToSession.get(fullPath);
if (!sessionKey) return;
const state = this.sessions.get(sessionKey);
if (!state) return;
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)
if (fileSize < state.lastOffset) {
if (this.logger) {
this.logger.warn(
{ sessionKey, fileSize, lastOffset: state.lastOffset },
'Transcript truncated (compaction detected) — resetting offset',
);
}
state.lastOffset = 0;
state.lines = ['[session compacted - continuing]'];
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
const chunk = buffer.toString('utf8', 0, bytesRead);
const lines = chunk.split('\n').filter((l) => l.trim());
for (const line of lines) {
this._parseLine(sessionKey, state, line);
}
// 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;
}
const { type } = record;
switch (type) {
case 'tool_call': {
state.pendingToolCalls++;
const toolName = record.name || record.tool || 'unknown';
const label = resolveLabel(toolName);
const statusLine = ` ${toolName}: ${label}`;
state.lines.push(statusLine);
break;
}
case 'tool_result': {
if (state.pendingToolCalls > 0) state.pendingToolCalls--;
const toolName = record.name || record.tool || 'unknown';
// Update the last tool_call line for this tool to show [OK] or [ERR]
const marker = record.error ? '[ERR]' : '[OK]';
const idx = findLastIndex(state.lines, (l) => l.includes(` ${toolName}:`));
if (idx >= 0) {
// Replace placeholder with result
state.lines[idx] = state.lines[idx].replace(/( \[OK\]| \[ERR\])?$/, ` ${marker}`);
}
break;
}
case 'assistant': {
// Assistant text chunk
const text = (record.text || record.content || '').trim();
if (text) {
const truncated = text.length > 80 ? text.slice(0, 77) + '...' : text;
state.lines.push(truncated);
}
break;
}
case 'usage': {
// Token usage update
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:
// Ignore unknown record types
break;
}
}
/**
* Schedule an idle check for a session.
* @private
*/
_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;
if (elapsed >= idleMs && state.pendingToolCalls === 0) {
if (this.logger) {
this.logger.info({ sessionKey, elapsedS: Math.floor(elapsed / 1000) }, 'Session idle');
}
state.status = 'done';
state.idleTimer = null;
this.emit('session-idle', sessionKey, this._sanitizeState(state));
} else {
// Reschedule
this._scheduleIdleCheck(sessionKey, state);
}
}
/**
* Return a safe copy of session state (without circular refs, timers).
* @private
*/
_sanitizeState(state) {
return {
sessionKey: state.sessionKey,
transcriptFile: state.transcriptFile,
status: state.status,
startTime: state.startTime,
lines: [...state.lines],
pendingToolCalls: state.pendingToolCalls,
lastOffset: state.lastOffset,
lastActivityAt: state.lastActivityAt,
agentId: state.agentId,
depth: state.depth,
tokenCount: state.tokenCount,
children: state.children,
};
}
}
/**
* Extract agent ID from session key.
* @param {string} sessionKey
* @returns {string}
*/
function extractAgentId(sessionKey) {
if (!sessionKey) return 'unknown';
const parts = sessionKey.split(':');
if (parts[0] === 'agent' && parts[1]) return parts[1];
return parts[0] || 'unknown';
}
/**
* Find the last index in an array satisfying a predicate.
* @param {Array} arr
* @param {Function} predicate
* @returns {number}
*/
function findLastIndex(arr, predicate) {
for (let i = arr.length - 1; i >= 0; i--) {
if (predicate(arr[i])) return i; // eslint-disable-line security/detect-object-injection
}
return -1;
}
module.exports = { StatusWatcher };

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

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

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

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

View File

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

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

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

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

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

View File

@@ -0,0 +1,207 @@
'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
const childLine = result.split('\n').find((l) => l.includes('proj035-planner'));
assert.ok(childLine && childLine.startsWith(' '));
});
it('active session has no done footer', () => {
const state = makeState({ status: 'active' });
const result = format(state);
const lines = result.split('\n');
// No line should contain [DONE], [ERROR], [INTERRUPTED]
assert.ok(!lines.some((l) => /\[(DONE|ERROR|INTERRUPTED)\]/.test(l)));
});
});
describe('formatElapsed()', () => {
it('formats seconds', () => {
assert.equal(formatElapsed(0), '0s');
assert.equal(formatElapsed(1000), '1s');
assert.equal(formatElapsed(59000), '59s');
});
it('formats minutes', () => {
assert.equal(formatElapsed(60000), '1m0s');
assert.equal(formatElapsed(90000), '1m30s');
assert.equal(formatElapsed(3599000), '59m59s');
});
it('formats hours', () => {
assert.equal(formatElapsed(3600000), '1h0m');
assert.equal(formatElapsed(7260000), '2h1m');
});
it('handles negative values', () => {
assert.equal(formatElapsed(-1000), '0s');
});
});
describe('formatTokens()', () => {
it('formats small counts', () => {
assert.equal(formatTokens(0), '0');
assert.equal(formatTokens(999), '999');
});
it('formats thousands', () => {
assert.equal(formatTokens(1000), '1.0k');
assert.equal(formatTokens(12400), '12.4k');
assert.equal(formatTokens(999900), '999.9k');
});
it('formats millions', () => {
assert.equal(formatTokens(1000000), '1.0M');
assert.equal(formatTokens(2500000), '2.5M');
});
});
describe('statusIcon()', () => {
it('returns correct icons', () => {
assert.equal(statusIcon('active'), '[ACTIVE]');
assert.equal(statusIcon('done'), '[DONE]');
assert.equal(statusIcon('error'), '[ERROR]');
assert.equal(statusIcon('interrupted'), '[INTERRUPTED]');
assert.equal(statusIcon('unknown'), '[UNKNOWN]');
assert.equal(statusIcon(''), '[UNKNOWN]');
});
});
describe('truncateLine()', () => {
it('does not truncate short lines', () => {
const line = 'Short line';
assert.equal(truncateLine(line), line);
});
it('truncates long lines', () => {
const line = 'x'.repeat(200);
const result = truncateLine(line);
assert.ok(result.length <= 120);
assert.ok(result.endsWith('...'));
});
});
describe('extractAgentId()', () => {
it('extracts agent ID from session key', () => {
assert.equal(extractAgentId('agent:main:mattermost:channel:abc'), 'main');
assert.equal(extractAgentId('agent:coder-agent:session:123'), 'coder-agent');
});
it('handles non-standard keys', () => {
assert.equal(extractAgentId('main'), 'main');
assert.equal(extractAgentId(''), 'unknown');
});
it('handles null/undefined', () => {
assert.equal(extractAgentId(null), 'unknown');
assert.equal(extractAgentId(undefined), 'unknown');
});
});
});

View File

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