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
|
# status-watcher-hook
|
||||||
|
|
||||||
Auto-starts the Live Status v4 daemon when the OpenClaw gateway starts.
|
Auto-starts the Live Status v4 daemon when the OpenClaw gateway starts.
|
||||||
|
|
||||||
## Events
|
|
||||||
|
|
||||||
```json
|
|
||||||
["gateway:startup"]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
On gateway startup, this hook checks whether the status-watcher daemon is already
|
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:
|
The following environment variables must be set for the watcher to function:
|
||||||
|
|
||||||
```
|
```
|
||||||
MM_TOKEN Mattermost bot token
|
MM_BOT_TOKEN Mattermost bot token
|
||||||
MM_URL Mattermost base URL (e.g. https://slack.solio.tech)
|
MM_BASE_URL Mattermost base URL (e.g. https://slack.solio.tech)
|
||||||
TRANSCRIPT_DIR Path to agent sessions directory
|
|
||||||
SESSIONS_JSON Path to sessions.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Optional (defaults shown):
|
Optional (defaults shown):
|
||||||
|
|
||||||
```
|
```
|
||||||
|
TRANSCRIPT_DIR /home/node/.openclaw/agents
|
||||||
THROTTLE_MS 500
|
THROTTLE_MS 500
|
||||||
IDLE_TIMEOUT_S 60
|
IDLE_TIMEOUT_S 60
|
||||||
MAX_STATUS_LINES 15
|
MAX_STATUS_LINES 20
|
||||||
MAX_ACTIVE_SESSIONS 20
|
MAX_ACTIVE_SESSIONS 20
|
||||||
MAX_MESSAGE_CHARS 15000
|
MAX_MESSAGE_CHARS 15000
|
||||||
HEALTH_PORT 9090
|
HEALTH_PORT 9090
|
||||||
|
|||||||
@@ -99,4 +99,6 @@ async function handle(_event) {
|
|||||||
spawnWatcher();
|
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}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
_transcriptPath(agentId, sessionId) {
|
_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);
|
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
|
// Sub-agents always pass through — they inherit parent channel via watcher-manager
|
||||||
const isSubAgent = !!spawnedBy;
|
const isSubAgent = !!spawnedBy;
|
||||||
|
|
||||||
|
|||||||
@@ -251,43 +251,106 @@ class StatusWatcher extends EventEmitter {
|
|||||||
return;
|
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': {
|
case 'tool_call': {
|
||||||
state.pendingToolCalls++;
|
state.pendingToolCalls++;
|
||||||
const toolName = record.name || record.tool || 'unknown';
|
var tn = record.name || record.tool || 'unknown';
|
||||||
const label = resolveLabel(toolName);
|
var lb = resolveLabel(tn);
|
||||||
const statusLine = ` ${toolName}: ${label}`;
|
state.lines.push(tn + ': ' + lb);
|
||||||
state.lines.push(statusLine);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'tool_result': {
|
case 'tool_result': {
|
||||||
if (state.pendingToolCalls > 0) state.pendingToolCalls--;
|
if (state.pendingToolCalls > 0) state.pendingToolCalls--;
|
||||||
const toolName = record.name || record.tool || 'unknown';
|
var rtn = record.name || record.tool || 'unknown';
|
||||||
// Update the last tool_call line for this tool to show [OK] or [ERR]
|
var mk = record.error ? '[ERR]' : '[OK]';
|
||||||
const marker = record.error ? '[ERR]' : '[OK]';
|
var ri = findLastIndex(state.lines, function (l) {
|
||||||
const idx = findLastIndex(state.lines, (l) => l.includes(` ${toolName}:`));
|
return l.indexOf(rtn + ':') === 0;
|
||||||
if (idx >= 0) {
|
});
|
||||||
// Replace placeholder with result
|
if (ri >= 0) {
|
||||||
state.lines[idx] = state.lines[idx].replace(/( \[OK\]| \[ERR\])?$/, ` ${marker}`);
|
state.lines[ri] = state.lines[ri].replace(/( \[OK\]| \[ERR\])?$/, ' ' + mk);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'assistant': {
|
case 'assistant': {
|
||||||
// Assistant text chunk
|
var at = (record.text || record.content || '').trim();
|
||||||
const text = (record.text || record.content || '').trim();
|
if (at) {
|
||||||
if (text) {
|
var tr = at.length > 100 ? at.slice(0, 97) + '...' : at;
|
||||||
const truncated = text.length > 80 ? text.slice(0, 77) + '...' : text;
|
state.lines.push(tr);
|
||||||
state.lines.push(truncated);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'usage': {
|
case 'usage': {
|
||||||
// Token usage update
|
|
||||||
if (record.total_tokens) state.tokenCount = record.total_tokens;
|
if (record.total_tokens) state.tokenCount = record.total_tokens;
|
||||||
else if (record.input_tokens || record.output_tokens) {
|
else if (record.input_tokens || record.output_tokens) {
|
||||||
state.tokenCount = (record.input_tokens || 0) + (record.output_tokens || 0);
|
state.tokenCount = (record.input_tokens || 0) + (record.output_tokens || 0);
|
||||||
@@ -301,7 +364,6 @@ class StatusWatcher extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Ignore unknown record types
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user