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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user