fix: production deployment issues

1. session-monitor: handle timestamp-prefixed transcript filenames
   OpenClaw uses {ISO}_{sessionId}.jsonl — glob for *_{sessionId}.jsonl
   when direct path doesn't exist.

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

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

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

5. HOOK.md: add YAML frontmatter metadata for hook discovery.
This commit is contained in:
sol
2026-03-07 18:31:43 +00:00
parent 387998812c
commit 7c6c8a4432
4 changed files with 142 additions and 33 deletions

View File

@@ -1,13 +1,13 @@
---
name: status-watcher-hook
description: "Auto-starts the Live Status v4 watcher daemon on gateway startup"
metadata: { "openclaw": { "emoji": "📡", "events": ["gateway:startup"] } }
---
# status-watcher-hook
Auto-starts the Live Status v4 daemon when the OpenClaw gateway starts.
## Events
```json
["gateway:startup"]
```
## Description
On gateway startup, this hook checks whether the status-watcher daemon is already
@@ -20,18 +20,17 @@ of this hook handler.
The following environment variables must be set for the watcher to function:
```
MM_TOKEN Mattermost bot token
MM_URL Mattermost base URL (e.g. https://slack.solio.tech)
TRANSCRIPT_DIR Path to agent sessions directory
SESSIONS_JSON Path to sessions.json
MM_BOT_TOKEN Mattermost bot token
MM_BASE_URL Mattermost base URL (e.g. https://slack.solio.tech)
```
Optional (defaults shown):
```
TRANSCRIPT_DIR /home/node/.openclaw/agents
THROTTLE_MS 500
IDLE_TIMEOUT_S 60
MAX_STATUS_LINES 15
MAX_STATUS_LINES 20
MAX_ACTIVE_SESSIONS 20
MAX_MESSAGE_CHARS 15000
HEALTH_PORT 9090

View File

@@ -99,4 +99,6 @@ async function handle(_event) {
spawnWatcher();
}
module.exports = { handle };
// OpenClaw hook loader expects a default export
module.exports = handle;
module.exports.default = handle;

View File

@@ -113,7 +113,30 @@ class SessionMonitor extends EventEmitter {
* @returns {string}
*/
_transcriptPath(agentId, sessionId) {
return path.join(this.transcriptDir, agentId, 'sessions', `${sessionId}.jsonl`);
const sessionsDir = path.join(this.transcriptDir, agentId, 'sessions');
const directPath = path.join(sessionsDir, `${sessionId}.jsonl`);
// OpenClaw may use timestamp-prefixed filenames: {ISO}_{sessionId}.jsonl
// Check direct path first, then glob for *_{sessionId}.jsonl
if (fs.existsSync(directPath)) {
return directPath;
}
try {
const files = fs.readdirSync(sessionsDir);
const suffix = `${sessionId}.jsonl`;
const match = files.find(
(f) => f.endsWith(suffix) && f !== suffix && !f.endsWith('.deleted'),
);
if (match) {
return path.join(sessionsDir, match);
}
} catch (_e) {
// Directory doesn't exist or unreadable
}
// Fallback to direct path (will fail with ENOENT, which is handled upstream)
return directPath;
}
/**
@@ -226,6 +249,29 @@ class SessionMonitor extends EventEmitter {
const transcriptFile = this._transcriptPath(agentId, sessionId);
// Skip stale sessions — only track if transcript was modified in last 5 minutes
// This prevents creating status boxes for every old session in sessions.json
try {
const stat = fs.statSync(transcriptFile);
const ageMs = Date.now() - stat.mtimeMs;
const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
if (ageMs > STALE_THRESHOLD_MS) {
if (this.logger) {
this.logger.debug(
{ sessionKey, ageS: Math.floor(ageMs / 1000) },
'Skipping stale session (transcript not recently modified)',
);
}
return;
}
} catch (_e) {
// File doesn't exist — skip silently
if (this.logger) {
this.logger.debug({ sessionKey, transcriptFile }, 'Skipping session (transcript not found)');
}
return;
}
// Sub-agents always pass through — they inherit parent channel via watcher-manager
const isSubAgent = !!spawnedBy;

View File

@@ -251,43 +251,106 @@ class StatusWatcher extends EventEmitter {
return;
}
const { type } = record;
// OpenClaw transcript format: all records have type="message"
// with record.message.role = "assistant" | "toolResult" | "user"
// and record.message.content = array of {type: "text"|"toolCall", ...}
if (record.type === 'message' && record.message) {
const msg = record.message;
const role = msg.role;
switch (type) {
if (role === 'assistant') {
// Extract usage if present
if (msg.usage && msg.usage.totalTokens) {
state.tokenCount = msg.usage.totalTokens;
}
// Parse content items
const contentItems = Array.isArray(msg.content) ? msg.content : [];
for (var i = 0; i < contentItems.length; i++) {
var item = contentItems[i];
if (item.type === 'toolCall') {
state.pendingToolCalls++;
var toolName = item.name || 'unknown';
var label = resolveLabel(toolName);
// Show tool name with key arguments
var argStr = '';
if (item.arguments) {
if (item.arguments.command) {
argStr = item.arguments.command.slice(0, 60);
} else if (item.arguments.file_path || item.arguments.path) {
argStr = item.arguments.file_path || item.arguments.path;
} else if (item.arguments.query) {
argStr = item.arguments.query.slice(0, 60);
} else if (item.arguments.url) {
argStr = item.arguments.url.slice(0, 60);
}
}
var statusLine = argStr
? toolName + ': ' + argStr
: toolName + ': ' + label;
state.lines.push(statusLine);
} else if (item.type === 'text' && item.text) {
var text = item.text.trim();
if (text) {
var truncated = text.length > 100 ? text.slice(0, 97) + '...' : text;
state.lines.push(truncated);
}
}
}
} else if (role === 'toolResult') {
if (state.pendingToolCalls > 0) state.pendingToolCalls--;
var resultToolName = msg.toolName || 'unknown';
var isError = msg.isError === true;
var marker = isError ? '[ERR]' : '[OK]';
var idx = findLastIndex(state.lines, function (l) {
return l.indexOf(resultToolName + ':') === 0;
});
if (idx >= 0) {
state.lines[idx] = state.lines[idx].replace(/( \[OK\]| \[ERR\])?$/, ' ' + marker);
}
} else if (role === 'user') {
// User messages — could show "User: ..." but skip for now to reduce noise
}
return;
}
// Legacy format fallback (for compatibility)
var legacyType = record.type;
switch (legacyType) {
case 'tool_call': {
state.pendingToolCalls++;
const toolName = record.name || record.tool || 'unknown';
const label = resolveLabel(toolName);
const statusLine = ` ${toolName}: ${label}`;
state.lines.push(statusLine);
var tn = record.name || record.tool || 'unknown';
var lb = resolveLabel(tn);
state.lines.push(tn + ': ' + lb);
break;
}
case 'tool_result': {
if (state.pendingToolCalls > 0) state.pendingToolCalls--;
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}`);
var rtn = record.name || record.tool || 'unknown';
var mk = record.error ? '[ERR]' : '[OK]';
var ri = findLastIndex(state.lines, function (l) {
return l.indexOf(rtn + ':') === 0;
});
if (ri >= 0) {
state.lines[ri] = state.lines[ri].replace(/( \[OK\]| \[ERR\])?$/, ' ' + mk);
}
break;
}
case 'assistant': {
// 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);
var at = (record.text || record.content || '').trim();
if (at) {
var tr = at.length > 100 ? at.slice(0, 97) + '...' : at;
state.lines.push(tr);
}
break;
}
case 'usage': {
// 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);
@@ -301,7 +364,6 @@ class StatusWatcher extends EventEmitter {
}
default:
// Ignore unknown record types
break;
}
}