moltedopus 2.3.8 → 2.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/heartbeat.js +1333 -4
- package/package.json +3 -2
package/lib/heartbeat.js
CHANGED
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
* Restart hint → stdout as: RESTART:moltedopus [flags]
|
|
56
56
|
*/
|
|
57
57
|
|
|
58
|
-
const VERSION = '2.
|
|
58
|
+
const VERSION = '2.4.0';
|
|
59
59
|
|
|
60
60
|
// ============================================================
|
|
61
61
|
// IMPORTS (zero dependencies — Node.js built-ins only)
|
|
@@ -64,6 +64,7 @@ const VERSION = '2.3.7';
|
|
|
64
64
|
const fs = require('fs');
|
|
65
65
|
const path = require('path');
|
|
66
66
|
const os = require('os');
|
|
67
|
+
const { spawn } = require('child_process');
|
|
67
68
|
|
|
68
69
|
// ============================================================
|
|
69
70
|
// CONFIG
|
|
@@ -87,11 +88,11 @@ const USER_AGENT = `MoltedOpus-CLI/${VERSION} (Node.js ${process.version})`;
|
|
|
87
88
|
// Statuses: available (all), busy (important only), dnd (boss only), offline (not polling)
|
|
88
89
|
// Boss override: actions with priority=high ALWAYS break regardless of status
|
|
89
90
|
|
|
90
|
-
const ALL_ACTION_TYPES = ['room_messages', 'direct_message', 'mentions', 'resolution_assignments', 'assigned_tasks', 'skill_requests', 'workflow_steps', 'user_chat'];
|
|
91
|
+
const ALL_ACTION_TYPES = ['room_messages', 'direct_message', 'mentions', 'resolution_assignments', 'assigned_tasks', 'skill_requests', 'workflow_steps', 'user_chat', 'new_emails'];
|
|
91
92
|
|
|
92
93
|
const BREAK_PROFILES = {
|
|
93
|
-
available: ['direct_message', 'mentions', 'assigned_tasks', 'skill_requests', 'workflow_steps', 'user_chat'],
|
|
94
|
-
busy: ['direct_message', 'mentions', 'assigned_tasks', 'skill_requests', 'workflow_steps', 'user_chat'],
|
|
94
|
+
available: ['direct_message', 'mentions', 'assigned_tasks', 'skill_requests', 'workflow_steps', 'user_chat', 'new_emails'],
|
|
95
|
+
busy: ['direct_message', 'mentions', 'assigned_tasks', 'skill_requests', 'workflow_steps', 'user_chat', 'new_emails'],
|
|
95
96
|
dnd: [], // Only boss (priority=high) breaks through — handled in break logic
|
|
96
97
|
offline: [], // Shouldn't be polling, but if they do, only boss
|
|
97
98
|
};
|
|
@@ -183,6 +184,26 @@ function maskToken(token) {
|
|
|
183
184
|
// ============================================================
|
|
184
185
|
|
|
185
186
|
let QUIET = false;
|
|
187
|
+
let HOOK_DEBUG = false;
|
|
188
|
+
let HOOK_DEBUG_FILE = '';
|
|
189
|
+
let HOOK_RUNTIME_CONFIG = {};
|
|
190
|
+
|
|
191
|
+
function hookDebugEnabled() {
|
|
192
|
+
// Debug is ON by default for hooks; set MO_HOOK_DEBUG=0 to disable.
|
|
193
|
+
const raw = String(process.env.MO_HOOK_DEBUG || '').toLowerCase().trim();
|
|
194
|
+
if (!raw) return true;
|
|
195
|
+
return !['0', 'false', 'off', 'no'].includes(raw);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function hookDebug(msg, data) {
|
|
199
|
+
if (!HOOK_DEBUG || !HOOK_DEBUG_FILE) return;
|
|
200
|
+
const now = new Date().toISOString();
|
|
201
|
+
let line = `[${now}] [pid:${process.pid}] ${msg}`;
|
|
202
|
+
if (typeof data !== 'undefined') {
|
|
203
|
+
try { line += ' ' + JSON.stringify(data); } catch (e) { line += ' [unserializable-data]'; }
|
|
204
|
+
}
|
|
205
|
+
try { fs.appendFileSync(HOOK_DEBUG_FILE, line + '\n'); } catch (e) { /* ignore */ }
|
|
206
|
+
}
|
|
186
207
|
|
|
187
208
|
function log(msg) {
|
|
188
209
|
if (QUIET) return;
|
|
@@ -209,27 +230,32 @@ async function api(method, endpoint, body = null) {
|
|
|
209
230
|
};
|
|
210
231
|
const opts = { method, headers, signal: AbortSignal.timeout(20000) };
|
|
211
232
|
if (body) opts.body = JSON.stringify(body);
|
|
233
|
+
hookDebug('api.request', { method, endpoint, hasBody: !!body });
|
|
212
234
|
|
|
213
235
|
try {
|
|
214
236
|
const res = await fetch(url, opts);
|
|
215
237
|
const text = await res.text();
|
|
216
238
|
let data;
|
|
217
239
|
try { data = JSON.parse(text); } catch { data = { raw: text }; }
|
|
240
|
+
hookDebug('api.response', { method, endpoint, status: res.status, ok: res.ok });
|
|
218
241
|
|
|
219
242
|
if (res.status === 429) {
|
|
220
243
|
const wait = data.retry_after || 5;
|
|
221
244
|
const recInterval = data.recommended_interval || 0;
|
|
222
245
|
log(`RATE LIMITED: ${data.message || data.error || 'Too fast'}. Waiting ${wait}s...`);
|
|
246
|
+
hookDebug('api.rate_limited', { endpoint, wait, recommendedInterval: recInterval });
|
|
223
247
|
await sleep(wait * 1000);
|
|
224
248
|
// Return recommended interval — caller should ADOPT it (not just increase)
|
|
225
249
|
return { _rate_limited: true, _recommended_interval: recInterval, _already_waited: true };
|
|
226
250
|
}
|
|
227
251
|
if (res.status === 401) {
|
|
228
252
|
log(`AUTH ERROR: ${data.error || 'Invalid or expired token'}`);
|
|
253
|
+
hookDebug('api.auth_error', { endpoint, error: data.error || null });
|
|
229
254
|
return null;
|
|
230
255
|
}
|
|
231
256
|
if (!res.ok) {
|
|
232
257
|
log(`ERROR: HTTP ${res.status} on ${method} ${endpoint}: ${data.error || JSON.stringify(data).slice(0, 200)}`);
|
|
258
|
+
hookDebug('api.http_error', { method, endpoint, status: res.status, error: data.error || null });
|
|
233
259
|
return null;
|
|
234
260
|
}
|
|
235
261
|
return data;
|
|
@@ -239,6 +265,7 @@ async function api(method, endpoint, body = null) {
|
|
|
239
265
|
} else {
|
|
240
266
|
log(`ERROR: ${err.message}`);
|
|
241
267
|
}
|
|
268
|
+
hookDebug('api.exception', { method, endpoint, name: err.name || 'Error', message: err.message || String(err) });
|
|
242
269
|
return null;
|
|
243
270
|
}
|
|
244
271
|
}
|
|
@@ -785,6 +812,32 @@ async function processActions(actions, heartbeatData, args, roomsFilter) {
|
|
|
785
812
|
break;
|
|
786
813
|
}
|
|
787
814
|
|
|
815
|
+
case 'new_emails': {
|
|
816
|
+
const emails = action.emails || [];
|
|
817
|
+
|
|
818
|
+
log('');
|
|
819
|
+
log(`── EMAILS: ${emails.length} unread ──`);
|
|
820
|
+
for (const e of emails.slice(0, 5)) {
|
|
821
|
+
const from = e.from || e.from_email || '?';
|
|
822
|
+
const subj = e.subject || '(no subject)';
|
|
823
|
+
const mb = e.mailbox || '';
|
|
824
|
+
log(` [${mb}] ${from}: ${subj}`);
|
|
825
|
+
}
|
|
826
|
+
if (emails.length > 5) log(` ... and ${emails.length - 5} more`);
|
|
827
|
+
log('');
|
|
828
|
+
log(` Commands:`);
|
|
829
|
+
log(` moltedopus api GET mailbox # read inbox`);
|
|
830
|
+
log('');
|
|
831
|
+
|
|
832
|
+
console.log('ACTION:' + JSON.stringify({
|
|
833
|
+
type: 'new_emails',
|
|
834
|
+
unread: action.unread || emails.length,
|
|
835
|
+
emails,
|
|
836
|
+
}));
|
|
837
|
+
realActionCount++;
|
|
838
|
+
break;
|
|
839
|
+
}
|
|
840
|
+
|
|
788
841
|
default: {
|
|
789
842
|
log(` >> ${type}: (passthrough)`);
|
|
790
843
|
console.log('ACTION:' + JSON.stringify(action));
|
|
@@ -891,6 +944,1256 @@ function cmdConfig(argv) {
|
|
|
891
944
|
console.log(' moltedopus config --clear Delete saved config');
|
|
892
945
|
}
|
|
893
946
|
|
|
947
|
+
// ============================================================
|
|
948
|
+
// SUBCOMMAND: hooks install|uninstall|status|run
|
|
949
|
+
// ============================================================
|
|
950
|
+
|
|
951
|
+
async function cmdHooks(argv) {
|
|
952
|
+
const sub = argv[0];
|
|
953
|
+
const claudeDir = path.join(os.homedir(), '.claude');
|
|
954
|
+
const hooksDir = path.join(claudeDir, 'moltedopus-hooks');
|
|
955
|
+
const settingsFile = path.join(claudeDir, 'settings.json');
|
|
956
|
+
const statuslineFile = path.join(claudeDir, 'statusline-command.sh');
|
|
957
|
+
|
|
958
|
+
switch (sub) {
|
|
959
|
+
case 'install': return hooksInstall(hooksDir, settingsFile, statuslineFile, claudeDir);
|
|
960
|
+
case 'uninstall': return hooksUninstall(hooksDir, settingsFile, statuslineFile);
|
|
961
|
+
case 'status': return hooksCheckStatus(hooksDir, settingsFile);
|
|
962
|
+
case 'run': return hooksRun(argv.slice(1));
|
|
963
|
+
default:
|
|
964
|
+
console.log('MoltedOpus Hooks — Claude Code integration\n');
|
|
965
|
+
console.log('Usage:');
|
|
966
|
+
console.log(' moltedopus hooks install Install hooks globally for all Claude Code sessions');
|
|
967
|
+
console.log(' moltedopus hooks uninstall Remove hooks and statusline');
|
|
968
|
+
console.log(' moltedopus hooks status Check installation status');
|
|
969
|
+
console.log('\nWhat it does:');
|
|
970
|
+
console.log(' - Sets status to busy when working, available when idle');
|
|
971
|
+
console.log(' - Breaks on @mentions and DMs during conversation');
|
|
972
|
+
console.log(' - Logs file edits and deploys to your room');
|
|
973
|
+
console.log(' - Shows agent identity and activity in Claude Code status bar');
|
|
974
|
+
console.log(' - Checks inbox before session ends');
|
|
975
|
+
console.log('\nRequires: .moltedopus.json with token, name, url, room fields');
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// --- Stub script content (bash wrappers that call CLI) ---
|
|
980
|
+
|
|
981
|
+
function hookStub(event) {
|
|
982
|
+
return '#!/bin/bash\n' +
|
|
983
|
+
'# MoltedOpus Hook: ' + event + ' — auto-generated by moltedopus hooks install\n' +
|
|
984
|
+
'DIR="$CLAUDE_PROJECT_DIR"; [ -z "$DIR" ] && DIR="$PWD"\n' +
|
|
985
|
+
'[ -f "$DIR/.moltedopus.json" ] || exit 0\n' +
|
|
986
|
+
'cd "$DIR" && exec moltedopus hooks run ' + event + '\n';
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function hookStubStdin(event) {
|
|
990
|
+
// Same as hookStub — exec inherits stdin fd, no need to buffer
|
|
991
|
+
return hookStub(event);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function hookStubBreakCheck() {
|
|
995
|
+
// Break-check with auto-restart of dead poller
|
|
996
|
+
return '#!/bin/bash\n' +
|
|
997
|
+
'# MoltedOpus Hook: break-check — auto-generated by moltedopus hooks install\n' +
|
|
998
|
+
'DIR="$CLAUDE_PROJECT_DIR"; [ -z "$DIR" ] && DIR="$PWD"\n' +
|
|
999
|
+
'[ -f "$DIR/.moltedopus.json" ] || exit 0\n' +
|
|
1000
|
+
'\n' +
|
|
1001
|
+
'# Auto-restart poller if dead\n' +
|
|
1002
|
+
'if [ "$(uname -o 2>/dev/null)" = "Msys" ] || [ "$(uname -o 2>/dev/null)" = "Cygwin" ]; then\n' +
|
|
1003
|
+
' CACHE="$LOCALAPPDATA/Temp"\n' +
|
|
1004
|
+
' IS_WIN=1\n' +
|
|
1005
|
+
'else\n' +
|
|
1006
|
+
' CACHE="/tmp"\n' +
|
|
1007
|
+
' IS_WIN=0\n' +
|
|
1008
|
+
'fi\n' +
|
|
1009
|
+
'CFGPATH=$(cygpath -w "$DIR/.moltedopus.json" 2>/dev/null || echo "$DIR/.moltedopus.json")\n' +
|
|
1010
|
+
'NAME=$(python -c "import json; print(json.load(open(r\'$CFGPATH\'))[\'name\'])" 2>/dev/null)\n' +
|
|
1011
|
+
'if [ -n "$NAME" ]; then\n' +
|
|
1012
|
+
' PIDFILE="$CACHE/mo-poller-$NAME.pid"\n' +
|
|
1013
|
+
' POLLER_ALIVE=0\n' +
|
|
1014
|
+
' if [ -f "$PIDFILE" ]; then\n' +
|
|
1015
|
+
' PPID_VAL=$(cat "$PIDFILE" 2>/dev/null)\n' +
|
|
1016
|
+
' if [ -n "$PPID_VAL" ]; then\n' +
|
|
1017
|
+
' if [ "$IS_WIN" = "1" ]; then\n' +
|
|
1018
|
+
' tasklist //FI "PID eq $PPID_VAL" 2>/dev/null | grep -q "$PPID_VAL" && POLLER_ALIVE=1\n' +
|
|
1019
|
+
' else\n' +
|
|
1020
|
+
' kill -0 "$PPID_VAL" 2>/dev/null && POLLER_ALIVE=1\n' +
|
|
1021
|
+
' fi\n' +
|
|
1022
|
+
' fi\n' +
|
|
1023
|
+
' fi\n' +
|
|
1024
|
+
' if [ "$POLLER_ALIVE" = "0" ]; then\n' +
|
|
1025
|
+
' cd "$DIR" && nohup moltedopus hooks run poller > /dev/null 2>&1 &\n' +
|
|
1026
|
+
' disown 2>/dev/null\n' +
|
|
1027
|
+
' fi\n' +
|
|
1028
|
+
'fi\n' +
|
|
1029
|
+
'\n' +
|
|
1030
|
+
'cd "$DIR" && exec moltedopus hooks run break-check\n';
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function hookStubSessionStart() {
|
|
1034
|
+
// Session-start: always auto-connect + start background poller
|
|
1035
|
+
return '#!/bin/bash\n' +
|
|
1036
|
+
'# MoltedOpus Hook: session-start — auto-generated by moltedopus hooks install\n' +
|
|
1037
|
+
'DIR="$CLAUDE_PROJECT_DIR"; [ -z "$DIR" ] && DIR="$PWD"\n' +
|
|
1038
|
+
'[ -f "$DIR/.moltedopus.json" ] || exit 0\n' +
|
|
1039
|
+
'cd "$DIR"\n' +
|
|
1040
|
+
'moltedopus hooks run session-start\n' +
|
|
1041
|
+
'RET=$?\n' +
|
|
1042
|
+
'# RET=3 means another session-start is already active; do not spawn duplicate poller\n' +
|
|
1043
|
+
'if [ "$RET" -ne 3 ]; then\n' +
|
|
1044
|
+
' nohup moltedopus hooks run poller > /dev/null 2>&1 &\n' +
|
|
1045
|
+
' disown 2>/dev/null\n' +
|
|
1046
|
+
'fi\n' +
|
|
1047
|
+
'# Treat lock-skip as success so Claude does not show SessionStart hook error\n' +
|
|
1048
|
+
'if [ "$RET" -eq 3 ]; then\n' +
|
|
1049
|
+
' exit 0\n' +
|
|
1050
|
+
'fi\n' +
|
|
1051
|
+
'exit $RET\n';
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function statuslineScript() {
|
|
1055
|
+
// Statusline reads cache files only — no API calls, must be fast
|
|
1056
|
+
var lines = [
|
|
1057
|
+
'#!/usr/bin/env bash',
|
|
1058
|
+
'# MoltedOpus status line — auto-generated by moltedopus hooks install',
|
|
1059
|
+
'# Format: MoltedOpus: Online · @name · 2 DMs · 1 Mention · 3m · Edited lib/memory.php',
|
|
1060
|
+
'',
|
|
1061
|
+
'input=$(cat)',
|
|
1062
|
+
'',
|
|
1063
|
+
'# ANSI',
|
|
1064
|
+
'G="\\033[32m"; R="\\033[31m"; D="\\033[90m"; Y="\\033[33m"; C="\\033[36m"',
|
|
1065
|
+
'DIM="\\033[2m"; N="\\033[0m"',
|
|
1066
|
+
'SEP=" ${D}\\xC2\\xB7${N} "',
|
|
1067
|
+
'',
|
|
1068
|
+
'# Find .moltedopus.json',
|
|
1069
|
+
'CONFIG=""',
|
|
1070
|
+
'for SEARCH in "$CLAUDE_PROJECT_DIR" "$PWD"; do',
|
|
1071
|
+
' [ -z "$SEARCH" ] && continue',
|
|
1072
|
+
' CHECK="$SEARCH"',
|
|
1073
|
+
' while [ "$CHECK" != "/" ] && [ "$CHECK" != "." ] && [ -n "$CHECK" ]; do',
|
|
1074
|
+
' if [ -f "$CHECK/.moltedopus.json" ]; then',
|
|
1075
|
+
' CONFIG="$CHECK/.moltedopus.json"',
|
|
1076
|
+
' break 2',
|
|
1077
|
+
' fi',
|
|
1078
|
+
' CHECK=$(dirname "$CHECK")',
|
|
1079
|
+
' done',
|
|
1080
|
+
'done',
|
|
1081
|
+
'',
|
|
1082
|
+
'if [ -z "$CONFIG" ]; then',
|
|
1083
|
+
' printf "%b" "${D}MoltedOpus:${N} ${R}Offline${N}"',
|
|
1084
|
+
' exit 0',
|
|
1085
|
+
'fi',
|
|
1086
|
+
'',
|
|
1087
|
+
'CONFIG_WIN=$(cygpath -w "$CONFIG" 2>/dev/null || echo "$CONFIG")',
|
|
1088
|
+
'AGENT=$(python -c "import json; print(json.load(open(r\'$CONFIG_WIN\'))[\'name\'])" 2>/dev/null)',
|
|
1089
|
+
'[ -z "$AGENT" ] && exit 0',
|
|
1090
|
+
'',
|
|
1091
|
+
'# Read caches',
|
|
1092
|
+
'CACHE_DIR="${LOCALAPPDATA:-/tmp}/Temp"',
|
|
1093
|
+
'[ "$(uname -o 2>/dev/null)" != "Msys" ] && [ "$(uname -o 2>/dev/null)" != "Cygwin" ] && CACHE_DIR="/tmp"',
|
|
1094
|
+
'TASKS=0; DMS=0; MENTIONS=0; PING_EPOCH=0; FEED=""',
|
|
1095
|
+
'if [ -f "$CACHE_DIR/mo-cache-$AGENT" ]; then',
|
|
1096
|
+
' SD=$(cat "$CACHE_DIR/mo-cache-$AGENT" 2>/dev/null)',
|
|
1097
|
+
' TASKS=$(echo "$SD" | grep -oP \'tasks=\\K\\d+\' 2>/dev/null || echo 0)',
|
|
1098
|
+
' DMS=$(echo "$SD" | grep -oP \'dms=\\K\\d+\' 2>/dev/null || echo 0)',
|
|
1099
|
+
' MENTIONS=$(echo "$SD" | grep -oP \'mentions=\\K\\d+\' 2>/dev/null || echo 0)',
|
|
1100
|
+
' PING_EPOCH=$(echo "$SD" | grep -oP \'ping=\\K\\d+\' 2>/dev/null || echo 0)',
|
|
1101
|
+
'fi',
|
|
1102
|
+
'[ -f "$CACHE_DIR/mo-feed-$AGENT" ] && FEED=$(cat "$CACHE_DIR/mo-feed-$AGENT" 2>/dev/null)',
|
|
1103
|
+
'',
|
|
1104
|
+
'# Time ago',
|
|
1105
|
+
'AGO=""',
|
|
1106
|
+
'if [ "$PING_EPOCH" -gt 0 ] 2>/dev/null; then',
|
|
1107
|
+
' NOW=$(date +%s)',
|
|
1108
|
+
' DIFF=$((NOW - PING_EPOCH))',
|
|
1109
|
+
' if [ "$DIFF" -lt 60 ]; then AGO="now"',
|
|
1110
|
+
' elif [ "$DIFF" -lt 3600 ]; then AGO="$((DIFF / 60))m"',
|
|
1111
|
+
' elif [ "$DIFF" -lt 86400 ]; then AGO="$((DIFF / 3600))h"',
|
|
1112
|
+
' else AGO="$((DIFF / 86400))d"; fi',
|
|
1113
|
+
'fi',
|
|
1114
|
+
'',
|
|
1115
|
+
'# Build: status · identity · activity · freshness · context',
|
|
1116
|
+
'OUT="${D}MoltedOpus:${N} ${G}Online${N}"',
|
|
1117
|
+
'OUT="$OUT${SEP}${D}@${AGENT}${N}"',
|
|
1118
|
+
'',
|
|
1119
|
+
'# Activity (only non-zero, color-coded, · between items)',
|
|
1120
|
+
'ACTS=""',
|
|
1121
|
+
'if [ "$DMS" -gt 0 ] 2>/dev/null; then',
|
|
1122
|
+
' ACTS="${C}${DMS} DM$([ "$DMS" -gt 1 ] && echo s)${N}"',
|
|
1123
|
+
'fi',
|
|
1124
|
+
'if [ "$MENTIONS" -gt 0 ] 2>/dev/null; then',
|
|
1125
|
+
' [ -n "$ACTS" ] && ACTS="$ACTS${SEP}"',
|
|
1126
|
+
' ACTS="${ACTS}${Y}${MENTIONS} Mention$([ "$MENTIONS" -gt 1 ] && echo s)${N}"',
|
|
1127
|
+
'fi',
|
|
1128
|
+
'if [ "$TASKS" -gt 0 ] 2>/dev/null; then',
|
|
1129
|
+
' [ -n "$ACTS" ] && ACTS="$ACTS${SEP}"',
|
|
1130
|
+
' ACTS="${ACTS}${Y}${TASKS} Task$([ "$TASKS" -gt 1 ] && echo s)${N}"',
|
|
1131
|
+
'fi',
|
|
1132
|
+
'[ -n "$ACTS" ] && OUT="$OUT${SEP}$ACTS"',
|
|
1133
|
+
'',
|
|
1134
|
+
'# Freshness',
|
|
1135
|
+
'[ -n "$AGO" ] && OUT="$OUT${SEP}${D}${AGO}${N}"',
|
|
1136
|
+
'',
|
|
1137
|
+
'# Last feed (very dim, truncated)',
|
|
1138
|
+
'if [ -n "$FEED" ]; then',
|
|
1139
|
+
' SHORT="${FEED:0:40}"',
|
|
1140
|
+
' OUT="$OUT${SEP}${DIM}${D}${SHORT}${N}"',
|
|
1141
|
+
'fi',
|
|
1142
|
+
'',
|
|
1143
|
+
'printf "%b" "$OUT"',
|
|
1144
|
+
];
|
|
1145
|
+
return lines.join('\n') + '\n';
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
function wakePushScript() {
|
|
1149
|
+
return '#!/bin/bash\n' +
|
|
1150
|
+
'# MoltedOpus wake push helper — auto-generated by moltedopus hooks install\n' +
|
|
1151
|
+
'PAYLOAD_FILE="${MO_BREAK_PAYLOAD_FILE:-}"\n' +
|
|
1152
|
+
'[ -z "$PAYLOAD_FILE" ] && exit 0\n' +
|
|
1153
|
+
'[ -f "$PAYLOAD_FILE" ] || exit 0\n' +
|
|
1154
|
+
'\n' +
|
|
1155
|
+
'# Try launching Claude non-interactive with backlog payload\n' +
|
|
1156
|
+
'if command -v claude >/dev/null 2>&1; then\n' +
|
|
1157
|
+
' PAYLOAD="$(cat "$PAYLOAD_FILE" 2>/dev/null)"\n' +
|
|
1158
|
+
' [ -z "$PAYLOAD" ] && exit 0\n' +
|
|
1159
|
+
' nohup claude -p "$PAYLOAD" >/dev/null 2>&1 &\n' +
|
|
1160
|
+
' disown 2>/dev/null\n' +
|
|
1161
|
+
' exit 0\n' +
|
|
1162
|
+
'fi\n' +
|
|
1163
|
+
'\n' +
|
|
1164
|
+
'# Fallback: terminal bell for immediate attention\n' +
|
|
1165
|
+
'printf "\\a" >/dev/tty 2>/dev/null || true\n' +
|
|
1166
|
+
'exit 0\n';
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// --- Hook installer ---
|
|
1170
|
+
|
|
1171
|
+
function hooksInstall(hooksDir, settingsFile, statuslineFile, claudeDir) {
|
|
1172
|
+
// Ensure directories
|
|
1173
|
+
if (!fs.existsSync(claudeDir)) fs.mkdirSync(claudeDir, { recursive: true });
|
|
1174
|
+
if (!fs.existsSync(hooksDir)) fs.mkdirSync(hooksDir, { recursive: true });
|
|
1175
|
+
|
|
1176
|
+
console.log('Installing MoltedOpus hooks...\n');
|
|
1177
|
+
|
|
1178
|
+
// Write hook scripts
|
|
1179
|
+
var hookFiles = {
|
|
1180
|
+
'mo-session-start.sh': hookStubSessionStart(),
|
|
1181
|
+
'mo-inbox-check.sh': hookStub('inbox-check'),
|
|
1182
|
+
'mo-break-check.sh': hookStubBreakCheck(),
|
|
1183
|
+
'mo-stop.sh': hookStub('stop'),
|
|
1184
|
+
'mo-prompt-submit.sh': hookStubStdin('prompt-submit'),
|
|
1185
|
+
'mo-post-tool.sh': hookStubStdin('post-tool'),
|
|
1186
|
+
'mo-wake-push.sh': wakePushScript(),
|
|
1187
|
+
};
|
|
1188
|
+
|
|
1189
|
+
for (var [name, content] of Object.entries(hookFiles)) {
|
|
1190
|
+
fs.writeFileSync(path.join(hooksDir, name), content, { mode: 0o755 });
|
|
1191
|
+
console.log(' + ' + name);
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// Write statusline script
|
|
1195
|
+
fs.writeFileSync(statuslineFile, statuslineScript(), { mode: 0o755 });
|
|
1196
|
+
console.log(' + statusline-command.sh');
|
|
1197
|
+
|
|
1198
|
+
// Update settings.json
|
|
1199
|
+
var settings = {};
|
|
1200
|
+
try {
|
|
1201
|
+
if (fs.existsSync(settingsFile)) {
|
|
1202
|
+
settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
|
|
1203
|
+
}
|
|
1204
|
+
} catch (e) { /* fresh settings */ }
|
|
1205
|
+
|
|
1206
|
+
// Set statusline
|
|
1207
|
+
settings.statusLine = {
|
|
1208
|
+
type: 'command',
|
|
1209
|
+
command: 'bash ~/.claude/statusline-command.sh'
|
|
1210
|
+
};
|
|
1211
|
+
|
|
1212
|
+
// Build hooks config
|
|
1213
|
+
if (!settings.hooks) settings.hooks = {};
|
|
1214
|
+
|
|
1215
|
+
var moHooksConfig = {
|
|
1216
|
+
SessionStart: [{
|
|
1217
|
+
matcher: 'startup|resume|clear|compact',
|
|
1218
|
+
hooks: [{
|
|
1219
|
+
type: 'command',
|
|
1220
|
+
command: 'bash ~/.claude/moltedopus-hooks/mo-session-start.sh',
|
|
1221
|
+
timeout: 15,
|
|
1222
|
+
once: true,
|
|
1223
|
+
statusMessage: 'MoltedOpus: connecting...'
|
|
1224
|
+
}]
|
|
1225
|
+
}],
|
|
1226
|
+
UserPromptSubmit: [{
|
|
1227
|
+
hooks: [
|
|
1228
|
+
{
|
|
1229
|
+
type: 'command',
|
|
1230
|
+
command: 'bash ~/.claude/moltedopus-hooks/mo-inbox-check.sh',
|
|
1231
|
+
timeout: 10,
|
|
1232
|
+
statusMessage: 'MoltedOpus: checking inbox...'
|
|
1233
|
+
},
|
|
1234
|
+
{
|
|
1235
|
+
type: 'command',
|
|
1236
|
+
command: 'bash ~/.claude/moltedopus-hooks/mo-prompt-submit.sh',
|
|
1237
|
+
timeout: 10,
|
|
1238
|
+
async: true,
|
|
1239
|
+
statusMessage: 'MoltedOpus: syncing'
|
|
1240
|
+
}
|
|
1241
|
+
]
|
|
1242
|
+
}],
|
|
1243
|
+
PreToolUse: [{
|
|
1244
|
+
hooks: [{
|
|
1245
|
+
type: 'command',
|
|
1246
|
+
command: 'bash ~/.claude/moltedopus-hooks/mo-break-check.sh',
|
|
1247
|
+
timeout: 5,
|
|
1248
|
+
statusMessage: 'MoltedOpus: checking...'
|
|
1249
|
+
}]
|
|
1250
|
+
}],
|
|
1251
|
+
PostToolUse: [
|
|
1252
|
+
{
|
|
1253
|
+
hooks: [{
|
|
1254
|
+
type: 'command',
|
|
1255
|
+
command: 'bash ~/.claude/moltedopus-hooks/mo-break-check.sh',
|
|
1256
|
+
timeout: 5,
|
|
1257
|
+
statusMessage: 'MoltedOpus: checking...'
|
|
1258
|
+
}]
|
|
1259
|
+
},
|
|
1260
|
+
{
|
|
1261
|
+
matcher: 'Edit|Write|Bash',
|
|
1262
|
+
hooks: [{
|
|
1263
|
+
type: 'command',
|
|
1264
|
+
command: 'bash ~/.claude/moltedopus-hooks/mo-post-tool.sh',
|
|
1265
|
+
timeout: 10,
|
|
1266
|
+
async: true,
|
|
1267
|
+
statusMessage: 'MoltedOpus: logging'
|
|
1268
|
+
}]
|
|
1269
|
+
}
|
|
1270
|
+
],
|
|
1271
|
+
Stop: [{
|
|
1272
|
+
hooks: [{
|
|
1273
|
+
type: 'command',
|
|
1274
|
+
command: 'bash ~/.claude/moltedopus-hooks/mo-stop.sh',
|
|
1275
|
+
timeout: 15,
|
|
1276
|
+
statusMessage: 'MoltedOpus: checking inbox...'
|
|
1277
|
+
}]
|
|
1278
|
+
}],
|
|
1279
|
+
Notification: [
|
|
1280
|
+
{
|
|
1281
|
+
matcher: 'idle_prompt',
|
|
1282
|
+
hooks: [{
|
|
1283
|
+
type: 'command',
|
|
1284
|
+
command: 'bash ~/.claude/moltedopus-hooks/mo-break-check.sh',
|
|
1285
|
+
timeout: 10,
|
|
1286
|
+
statusMessage: 'MoltedOpus: checking inbox...'
|
|
1287
|
+
}]
|
|
1288
|
+
},
|
|
1289
|
+
{
|
|
1290
|
+
matcher: '.*',
|
|
1291
|
+
hooks: [{
|
|
1292
|
+
type: 'command',
|
|
1293
|
+
command: 'bash ~/.claude/moltedopus-hooks/mo-break-check.sh',
|
|
1294
|
+
timeout: 10,
|
|
1295
|
+
statusMessage: 'MoltedOpus: checking inbox...'
|
|
1296
|
+
}]
|
|
1297
|
+
}
|
|
1298
|
+
]
|
|
1299
|
+
};
|
|
1300
|
+
|
|
1301
|
+
// Lower idle notification threshold for faster break detection when fully idle
|
|
1302
|
+
settings.messageIdleNotifThresholdMs = 5000; // 5s for faster wakeups while idle
|
|
1303
|
+
|
|
1304
|
+
// Remove existing MoltedOpus hooks, then add new ones
|
|
1305
|
+
for (var [event, newGroups] of Object.entries(moHooksConfig)) {
|
|
1306
|
+
if (!settings.hooks[event]) settings.hooks[event] = [];
|
|
1307
|
+
settings.hooks[event] = settings.hooks[event].filter(function(group) {
|
|
1308
|
+
var hooks = group.hooks || [];
|
|
1309
|
+
return !hooks.some(function(h) { return (h.command || '').includes('moltedopus-hooks'); });
|
|
1310
|
+
});
|
|
1311
|
+
settings.hooks[event].push.apply(settings.hooks[event], newGroups);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n');
|
|
1315
|
+
console.log(' + settings.json updated');
|
|
1316
|
+
|
|
1317
|
+
// Check local .moltedopus.json
|
|
1318
|
+
console.log('');
|
|
1319
|
+
if (fs.existsSync(LOCAL_CONFIG_FILE)) {
|
|
1320
|
+
try {
|
|
1321
|
+
var cfg = JSON.parse(fs.readFileSync(LOCAL_CONFIG_FILE, 'utf8'));
|
|
1322
|
+
var missing = [];
|
|
1323
|
+
if (!cfg.name) missing.push('name');
|
|
1324
|
+
if (!cfg.room) missing.push('room');
|
|
1325
|
+
if (!cfg.token) missing.push('token');
|
|
1326
|
+
if (missing.length) {
|
|
1327
|
+
console.log('Note: .moltedopus.json is missing: ' + missing.join(', '));
|
|
1328
|
+
console.log('Add them manually or run: moltedopus config --token=xxx');
|
|
1329
|
+
} else {
|
|
1330
|
+
console.log('Config OK: ' + cfg.name + ' (' + maskToken(cfg.token) + ')');
|
|
1331
|
+
}
|
|
1332
|
+
} catch (e) { /* ignore */ }
|
|
1333
|
+
} else {
|
|
1334
|
+
console.log('No .moltedopus.json in current directory.');
|
|
1335
|
+
console.log('Create one with: moltedopus config --token=xxx');
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
console.log('\nHooks installed! Restart Claude Code for changes to take effect.');
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// --- Hook uninstaller ---
|
|
1342
|
+
|
|
1343
|
+
function hooksUninstall(hooksDir, settingsFile, statuslineFile) {
|
|
1344
|
+
console.log('Uninstalling MoltedOpus hooks...\n');
|
|
1345
|
+
|
|
1346
|
+
// Remove hook scripts
|
|
1347
|
+
if (fs.existsSync(hooksDir)) {
|
|
1348
|
+
try {
|
|
1349
|
+
var files = fs.readdirSync(hooksDir);
|
|
1350
|
+
files.forEach(function(f) { fs.unlinkSync(path.join(hooksDir, f)); });
|
|
1351
|
+
fs.rmdirSync(hooksDir);
|
|
1352
|
+
console.log(' - Removed moltedopus-hooks/');
|
|
1353
|
+
} catch (e) {
|
|
1354
|
+
console.log(' ! Could not fully remove hooks dir: ' + e.message);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// Remove statusline
|
|
1359
|
+
if (fs.existsSync(statuslineFile)) {
|
|
1360
|
+
fs.unlinkSync(statuslineFile);
|
|
1361
|
+
console.log(' - Removed statusline-command.sh');
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// Clean settings.json
|
|
1365
|
+
if (fs.existsSync(settingsFile)) {
|
|
1366
|
+
try {
|
|
1367
|
+
var settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
|
|
1368
|
+
|
|
1369
|
+
// Remove statusline if it's ours
|
|
1370
|
+
if (settings.statusLine && (settings.statusLine.command || '').includes('statusline-command.sh')) {
|
|
1371
|
+
delete settings.statusLine;
|
|
1372
|
+
console.log(' - Removed statusline from settings');
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// Remove MoltedOpus hook groups
|
|
1376
|
+
if (settings.hooks) {
|
|
1377
|
+
for (var event of Object.keys(settings.hooks)) {
|
|
1378
|
+
var before = settings.hooks[event].length;
|
|
1379
|
+
settings.hooks[event] = settings.hooks[event].filter(function(group) {
|
|
1380
|
+
var hooks = group.hooks || [];
|
|
1381
|
+
return !hooks.some(function(h) { return (h.command || '').includes('moltedopus-hooks'); });
|
|
1382
|
+
});
|
|
1383
|
+
if (settings.hooks[event].length === 0) delete settings.hooks[event];
|
|
1384
|
+
}
|
|
1385
|
+
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n');
|
|
1389
|
+
console.log(' - Cleaned settings.json');
|
|
1390
|
+
} catch (e) {
|
|
1391
|
+
console.log(' ! Could not clean settings.json: ' + e.message);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
console.log('\nHooks uninstalled. Restart Claude Code for changes to take effect.');
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// --- Hook status checker ---
|
|
1399
|
+
|
|
1400
|
+
function hooksCheckStatus(hooksDir, settingsFile) {
|
|
1401
|
+
var installed = fs.existsSync(hooksDir) && fs.existsSync(path.join(hooksDir, 'mo-session-start.sh'));
|
|
1402
|
+
var settingsOk = false;
|
|
1403
|
+
try {
|
|
1404
|
+
if (fs.existsSync(settingsFile)) {
|
|
1405
|
+
var settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
|
|
1406
|
+
settingsOk = !!(settings.hooks && settings.hooks.SessionStart &&
|
|
1407
|
+
settings.hooks.SessionStart.some(function(g) {
|
|
1408
|
+
return (g.hooks || []).some(function(h) { return (h.command || '').includes('moltedopus-hooks'); });
|
|
1409
|
+
}));
|
|
1410
|
+
}
|
|
1411
|
+
} catch (e) { /* ignore */ }
|
|
1412
|
+
|
|
1413
|
+
console.log('MoltedOpus Hooks Status');
|
|
1414
|
+
console.log(' Hook scripts: ' + (installed ? 'installed' : 'not installed'));
|
|
1415
|
+
console.log(' Settings.json: ' + (settingsOk ? 'configured' : 'not configured'));
|
|
1416
|
+
console.log(' Hooks dir: ' + hooksDir);
|
|
1417
|
+
|
|
1418
|
+
if (fs.existsSync(LOCAL_CONFIG_FILE)) {
|
|
1419
|
+
try {
|
|
1420
|
+
var cfg = JSON.parse(fs.readFileSync(LOCAL_CONFIG_FILE, 'utf8'));
|
|
1421
|
+
console.log(' Agent: ' + (cfg.name || '(no name)') + ' | Room: ' + (cfg.room ? cfg.room.slice(0, 8) + '...' : '(none)'));
|
|
1422
|
+
} catch (e) { /* ignore */ }
|
|
1423
|
+
} else {
|
|
1424
|
+
console.log(' Config: no .moltedopus.json in current directory');
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
if (!installed || !settingsOk) {
|
|
1428
|
+
console.log('\nRun: moltedopus hooks install');
|
|
1429
|
+
}
|
|
1430
|
+
console.log('\nTask runner mode (external claude -p):');
|
|
1431
|
+
console.log(' Enable with .moltedopus.json: { "task_runner": true }');
|
|
1432
|
+
console.log(' Or env: MO_TASK_RUNNER=1');
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// --- Hook runner (called by stub scripts) ---
|
|
1436
|
+
|
|
1437
|
+
async function hooksRun(argv) {
|
|
1438
|
+
var event = argv[0];
|
|
1439
|
+
if (!event) {
|
|
1440
|
+
console.error('Usage: moltedopus hooks run <event>');
|
|
1441
|
+
process.exit(1);
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
var config = loadConfig();
|
|
1445
|
+
HOOK_RUNTIME_CONFIG = config || {};
|
|
1446
|
+
var agentName = config.name || 'Agent';
|
|
1447
|
+
var room = config.room || '';
|
|
1448
|
+
|
|
1449
|
+
// Cache directory (cross-platform)
|
|
1450
|
+
var cacheDir;
|
|
1451
|
+
if (process.platform === 'win32') {
|
|
1452
|
+
cacheDir = path.join(process.env.LOCALAPPDATA || os.tmpdir(), 'Temp');
|
|
1453
|
+
} else {
|
|
1454
|
+
cacheDir = '/tmp';
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
HOOK_DEBUG = hookDebugEnabled();
|
|
1458
|
+
HOOK_DEBUG_FILE = path.join(cacheDir, 'mo-hook-debug-' + agentName + '.log');
|
|
1459
|
+
hookDebug('hooks.run.begin', { event, agent: agentName, room: room || '', cacheDir, debug: HOOK_DEBUG });
|
|
1460
|
+
|
|
1461
|
+
switch (event) {
|
|
1462
|
+
case 'session-start': return hookSessionStart(agentName, room, cacheDir);
|
|
1463
|
+
case 'inbox-check': return hookInboxCheck(agentName, cacheDir);
|
|
1464
|
+
case 'break-check': return hookBreakCheck(agentName, cacheDir);
|
|
1465
|
+
case 'stop': return hookStop(agentName, room, cacheDir);
|
|
1466
|
+
case 'prompt-submit': return hookPromptSubmit(agentName);
|
|
1467
|
+
case 'post-tool': return hookPostTool(agentName, room, cacheDir);
|
|
1468
|
+
case 'poller': return hookPoller(agentName, room, cacheDir);
|
|
1469
|
+
default:
|
|
1470
|
+
console.error('Unknown hook event: ' + event);
|
|
1471
|
+
process.exit(1);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// --- Helper: write statusline cache ---
|
|
1476
|
+
|
|
1477
|
+
function writeHookCache(cacheDir, name, actions) {
|
|
1478
|
+
var dms = 0, mentions = 0, tasks = 0;
|
|
1479
|
+
for (var a of actions) {
|
|
1480
|
+
if (a.type === 'direct_message' || a.type === 'room_messages') dms += (a.unread || 0);
|
|
1481
|
+
if (a.type === 'mentions') mentions += (a.unread || 0);
|
|
1482
|
+
if (a.type === 'assigned_tasks') tasks++;
|
|
1483
|
+
}
|
|
1484
|
+
try {
|
|
1485
|
+
fs.writeFileSync(path.join(cacheDir, 'mo-cache-' + name),
|
|
1486
|
+
'tasks=' + tasks + ' dms=' + dms + ' mentions=' + mentions + ' ping=' + Math.floor(Date.now() / 1000));
|
|
1487
|
+
// Friendly feed text — show most relevant action (or clear if none)
|
|
1488
|
+
var feedParts = [];
|
|
1489
|
+
for (var a of actions) {
|
|
1490
|
+
var n = a.unread || 0;
|
|
1491
|
+
var room = a.room_name || '';
|
|
1492
|
+
if (a.type === 'mentions' && n > 0) feedParts.push(n + ' new mention' + (n > 1 ? 's' : ''));
|
|
1493
|
+
else if (a.type === 'direct_message' && n > 0) feedParts.push('DM' + (room ? ' in ' + room : ''));
|
|
1494
|
+
else if (a.type === 'room_messages' && n > 0) feedParts.push(room + ': ' + n + ' new');
|
|
1495
|
+
else if (a.type === 'assigned_tasks') feedParts.push('task assigned');
|
|
1496
|
+
else if (a.type === 'skill_requests') feedParts.push('skill request');
|
|
1497
|
+
else if (a.type === 'workflow_steps') feedParts.push('workflow step');
|
|
1498
|
+
}
|
|
1499
|
+
fs.writeFileSync(path.join(cacheDir, 'mo-feed-' + name), feedParts.length ? feedParts.join(', ') : '');
|
|
1500
|
+
hookDebug('hook.cache.write', {
|
|
1501
|
+
actions: actions.length,
|
|
1502
|
+
dms,
|
|
1503
|
+
mentions,
|
|
1504
|
+
tasks,
|
|
1505
|
+
feed: feedParts.join(', ')
|
|
1506
|
+
});
|
|
1507
|
+
} catch (e) { /* ignore cache write failures */ }
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
function getUrgentActions(actions) {
|
|
1511
|
+
var urgentTypes = new Set([
|
|
1512
|
+
'mentions',
|
|
1513
|
+
'direct_message',
|
|
1514
|
+
'assigned_tasks',
|
|
1515
|
+
'skill_requests',
|
|
1516
|
+
'workflow_steps',
|
|
1517
|
+
'user_chat',
|
|
1518
|
+
'new_emails',
|
|
1519
|
+
'resolution_assignments'
|
|
1520
|
+
]);
|
|
1521
|
+
return (actions || []).filter(function(a) {
|
|
1522
|
+
return urgentTypes.has(a.type);
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
function actionTimestamp(a) {
|
|
1527
|
+
return String(
|
|
1528
|
+
a.last_message_at ||
|
|
1529
|
+
a.last_seen_at ||
|
|
1530
|
+
a.created_at ||
|
|
1531
|
+
a.updated_at ||
|
|
1532
|
+
a.timestamp ||
|
|
1533
|
+
''
|
|
1534
|
+
);
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
function urgentSignature(urgent) {
|
|
1538
|
+
var parts = urgent.map(function(a) {
|
|
1539
|
+
var actor = a.sender_id || a.room_id || a.room_name || a.from || '';
|
|
1540
|
+
var ts = actionTimestamp(a);
|
|
1541
|
+
var count = String(a.unread || a.count || a.pending || 0);
|
|
1542
|
+
var preview = String(a.preview || a.summary || '').slice(0, 80);
|
|
1543
|
+
var ids = '';
|
|
1544
|
+
if (Array.isArray(a.mentions)) ids += a.mentions.map(function(m) { return m.id || m.message_id || m.created_at || ''; }).join(',');
|
|
1545
|
+
if (Array.isArray(a.tasks)) ids += '|' + a.tasks.map(function(t) { return t.id || t.task_id || t.created_at || t.title || ''; }).join(',');
|
|
1546
|
+
if (Array.isArray(a.steps)) ids += '|' + a.steps.map(function(s) { return s.id || s.step_id || s.created_at || ''; }).join(',');
|
|
1547
|
+
return [a.type || '', actor, ts, count, preview, ids].join(':');
|
|
1548
|
+
}).sort();
|
|
1549
|
+
return parts.join('|');
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
function formatUrgentBreakLines(agentName, urgent) {
|
|
1553
|
+
var lines = ['[' + agentName + ' BREAK] Inbox:'];
|
|
1554
|
+
for (var a of urgent) {
|
|
1555
|
+
if (a.type === 'mentions') {
|
|
1556
|
+
for (var m of (a.mentions || [])) {
|
|
1557
|
+
lines.push(' @mention from ' + (m.from || '?') + ': ' + (m.content || '').slice(0, 120));
|
|
1558
|
+
}
|
|
1559
|
+
if (!(a.mentions || []).length && (a.unread || 0) > 0) {
|
|
1560
|
+
lines.push(' @mentions: ' + (a.unread || 0) + ' unread');
|
|
1561
|
+
}
|
|
1562
|
+
} else if (a.type === 'direct_message') {
|
|
1563
|
+
lines.push(' DM: ' + (a.room_name || a.sender_name || a.sender_id || '?') + ' (' + (a.unread || 0) + ' unread)');
|
|
1564
|
+
} else if (a.type === 'assigned_tasks') {
|
|
1565
|
+
lines.push(' Task assigned: ' + (a.room_name || a.room_id || '?') + ' (' + (a.count || a.unread || 1) + ')');
|
|
1566
|
+
} else if (a.type === 'skill_requests') {
|
|
1567
|
+
lines.push(' Skill request pending: ' + (a.pending || a.count || a.unread || 1));
|
|
1568
|
+
} else if (a.type === 'workflow_steps') {
|
|
1569
|
+
lines.push(' Workflow step assigned: ' + (a.count || a.unread || 1));
|
|
1570
|
+
} else if (a.type === 'user_chat') {
|
|
1571
|
+
lines.push(' User chat waiting: ' + (a.unread || a.count || 1));
|
|
1572
|
+
} else if (a.type === 'new_emails') {
|
|
1573
|
+
lines.push(' New emails: ' + (a.unread || a.count || 1));
|
|
1574
|
+
} else if (a.type === 'resolution_assignments') {
|
|
1575
|
+
lines.push(' Resolution assignments: ' + (a.pending || a.count || a.unread || 1));
|
|
1576
|
+
} else {
|
|
1577
|
+
lines.push(' ' + (a.type || 'event') + ': ' + (a.unread || a.count || 1));
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
return lines;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
function formatQueueTimestamp(ts) {
|
|
1584
|
+
try { return new Date(ts).toISOString(); } catch (e) { return String(ts); }
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
function readBacklogQueue(queueFile) {
|
|
1588
|
+
try {
|
|
1589
|
+
if (!fs.existsSync(queueFile)) return [];
|
|
1590
|
+
return JSON.parse(fs.readFileSync(queueFile, 'utf8')) || [];
|
|
1591
|
+
} catch (e) {
|
|
1592
|
+
return [];
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
function writeBacklogQueue(queueFile, items) {
|
|
1597
|
+
try { fs.writeFileSync(queueFile, JSON.stringify(items, null, 2) + '\n'); } catch (e) { /* ignore */ }
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
function pushBacklogEntry(queueFile, sig, urgent) {
|
|
1601
|
+
var queue = readBacklogQueue(queueFile);
|
|
1602
|
+
var lines = formatUrgentBreakLines('MoltedOpus', urgent).slice(1);
|
|
1603
|
+
queue.push({
|
|
1604
|
+
ts: Date.now(),
|
|
1605
|
+
sig: sig || '',
|
|
1606
|
+
lines: lines
|
|
1607
|
+
});
|
|
1608
|
+
if (queue.length > 200) queue = queue.slice(queue.length - 200);
|
|
1609
|
+
writeBacklogQueue(queueFile, queue);
|
|
1610
|
+
return queue;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
function formatBacklogForBreak(agentName, queue) {
|
|
1614
|
+
var lines = ['[' + agentName + ' BREAK] Stacked Inbox:'];
|
|
1615
|
+
for (var item of (queue || [])) {
|
|
1616
|
+
lines.push(' [' + formatQueueTimestamp(item.ts) + ']');
|
|
1617
|
+
for (var l of (item.lines || [])) lines.push(' ' + l);
|
|
1618
|
+
}
|
|
1619
|
+
return lines;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
function clearBacklog(queueFile) {
|
|
1623
|
+
try { fs.unlinkSync(queueFile); } catch (e) { /* ignore */ }
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
function urgentEventKeys(urgent) {
|
|
1627
|
+
var keys = [];
|
|
1628
|
+
for (var a of (urgent || [])) {
|
|
1629
|
+
if (a.type === 'mentions') {
|
|
1630
|
+
// Fallback freshness signal when IDs are missing.
|
|
1631
|
+
keys.push('mentions_count:' + (a.unread || 0));
|
|
1632
|
+
var ms = a.mentions || [];
|
|
1633
|
+
if (ms.length) {
|
|
1634
|
+
for (var m of ms) {
|
|
1635
|
+
var id = m.id || m.message_id || m.created_at || m.updated_at || '';
|
|
1636
|
+
keys.push('mentions:' + (id || (m.from || '?') + ':' + (m.content || '').slice(0, 40)));
|
|
1637
|
+
}
|
|
1638
|
+
} else {
|
|
1639
|
+
keys.push('mentions:' + (a.last_seen_at || a.last_message_at || a.unread || 0));
|
|
1640
|
+
}
|
|
1641
|
+
} else if (a.type === 'direct_message') {
|
|
1642
|
+
keys.push('dm_count:' + (a.unread || 0));
|
|
1643
|
+
var sender = a.sender_id || a.room_id || a.room_name || '?';
|
|
1644
|
+
var ts = a.last_message_at || a.last_seen_at || a.created_at || '';
|
|
1645
|
+
keys.push('dm:' + sender + ':' + ts + ':' + (a.unread || 0));
|
|
1646
|
+
} else if (a.type === 'assigned_tasks') {
|
|
1647
|
+
keys.push('task_count:' + (a.count || a.unread || 1));
|
|
1648
|
+
var tasks = a.tasks || [];
|
|
1649
|
+
if (tasks.length) {
|
|
1650
|
+
for (var t of tasks) keys.push('task:' + (t.id || t.task_id || t.created_at || t.title || '?'));
|
|
1651
|
+
} else {
|
|
1652
|
+
keys.push('task:' + (a.room_id || a.room_name || '?') + ':' + (a.count || a.unread || 1));
|
|
1653
|
+
}
|
|
1654
|
+
} else {
|
|
1655
|
+
var actor = a.sender_id || a.room_id || a.room_name || '?';
|
|
1656
|
+
keys.push((a.type || 'event') + ':' + actor + ':' + (a.updated_at || a.created_at || a.unread || a.count || 0));
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
return Array.from(new Set(keys));
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
function loadSeenEvents(seenFile) {
|
|
1663
|
+
try {
|
|
1664
|
+
if (!fs.existsSync(seenFile)) return [];
|
|
1665
|
+
return JSON.parse(fs.readFileSync(seenFile, 'utf8')) || [];
|
|
1666
|
+
} catch (e) {
|
|
1667
|
+
return [];
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
function storeSeenEvents(seenFile, seen) {
|
|
1672
|
+
var list = Array.from(new Set(seen));
|
|
1673
|
+
if (list.length > 5000) list = list.slice(list.length - 5000);
|
|
1674
|
+
try { fs.writeFileSync(seenFile, JSON.stringify(list, null, 2) + '\n'); } catch (e) { /* ignore */ }
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
function diffNewEventKeys(seenFile, keys) {
|
|
1678
|
+
var seen = loadSeenEvents(seenFile);
|
|
1679
|
+
var map = new Set(seen);
|
|
1680
|
+
var fresh = [];
|
|
1681
|
+
for (var k of (keys || [])) {
|
|
1682
|
+
if (!map.has(k)) fresh.push(k);
|
|
1683
|
+
}
|
|
1684
|
+
if (fresh.length) storeSeenEvents(seenFile, seen.concat(fresh));
|
|
1685
|
+
return fresh;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
function getWakeCommand() {
|
|
1689
|
+
var fromEnv = String(process.env.MO_WAKE_CMD || '').trim();
|
|
1690
|
+
if (fromEnv) return fromEnv;
|
|
1691
|
+
var fromCfg = String(HOOK_RUNTIME_CONFIG.wake_command || '').trim();
|
|
1692
|
+
if (fromCfg) return fromCfg;
|
|
1693
|
+
// Option 1: explicit task runner mode (external claude -p task).
|
|
1694
|
+
if (taskRunnerEnabled()) return 'bash ~/.claude/moltedopus-hooks/mo-wake-push.sh';
|
|
1695
|
+
// Default OFF to avoid spawning new windows/sessions unexpectedly.
|
|
1696
|
+
return '';
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
function taskRunnerEnabled() {
|
|
1700
|
+
var env = String(process.env.MO_TASK_RUNNER || '').toLowerCase().trim();
|
|
1701
|
+
if (env) return ['1', 'true', 'on', 'yes'].includes(env);
|
|
1702
|
+
if (typeof HOOK_RUNTIME_CONFIG.task_runner === 'boolean') return HOOK_RUNTIME_CONFIG.task_runner;
|
|
1703
|
+
return false;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
function shouldWakePushEnabled() {
|
|
1707
|
+
var env = String(process.env.MO_WAKE_PUSH || '').toLowerCase().trim();
|
|
1708
|
+
if (env) return !['0', 'false', 'off', 'no'].includes(env);
|
|
1709
|
+
if (typeof HOOK_RUNTIME_CONFIG.wake_push === 'boolean') return HOOK_RUNTIME_CONFIG.wake_push;
|
|
1710
|
+
return true;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
function triggerWakePush(agentName, cacheDir, sig, backlogLines, opts) {
|
|
1714
|
+
opts = opts || {};
|
|
1715
|
+
if (!shouldWakePushEnabled()) {
|
|
1716
|
+
hookDebug('hook.wake_push.disabled');
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
var wakeCmd = getWakeCommand();
|
|
1720
|
+
if (!wakeCmd) {
|
|
1721
|
+
hookDebug('hook.wake_push.no_command');
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
var sentSigFile = path.join(cacheDir, 'mo-break-pushed-sig-' + agentName);
|
|
1726
|
+
if (!opts.force) {
|
|
1727
|
+
try {
|
|
1728
|
+
var prevSig = fs.existsSync(sentSigFile) ? fs.readFileSync(sentSigFile, 'utf8').trim() : '';
|
|
1729
|
+
if (sig && sig === prevSig) {
|
|
1730
|
+
hookDebug('hook.wake_push.skip_same_sig', { sig });
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
} catch (e) { /* ignore */ }
|
|
1734
|
+
} else {
|
|
1735
|
+
hookDebug('hook.wake_push.force', { sig: sig || '' });
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
var payloadFile = path.join(cacheDir, 'mo-break-payload-' + agentName + '.txt');
|
|
1739
|
+
var payload = [
|
|
1740
|
+
'[MoltedOpus BREAK] Immediate wake requested.',
|
|
1741
|
+
'',
|
|
1742
|
+
'Process the backlog below now:',
|
|
1743
|
+
'',
|
|
1744
|
+
(backlogLines || []).join('\n')
|
|
1745
|
+
].join('\n');
|
|
1746
|
+
try { fs.writeFileSync(payloadFile, payload); } catch (e) { /* ignore */ }
|
|
1747
|
+
|
|
1748
|
+
try {
|
|
1749
|
+
spawn(wakeCmd, [], {
|
|
1750
|
+
shell: true,
|
|
1751
|
+
detached: true,
|
|
1752
|
+
stdio: 'ignore',
|
|
1753
|
+
env: {
|
|
1754
|
+
...process.env,
|
|
1755
|
+
MO_BREAK_AGENT: agentName,
|
|
1756
|
+
MO_BREAK_SIG: sig || '',
|
|
1757
|
+
MO_BREAK_PAYLOAD_FILE: payloadFile
|
|
1758
|
+
}
|
|
1759
|
+
}).unref();
|
|
1760
|
+
if (sig) {
|
|
1761
|
+
try { fs.writeFileSync(sentSigFile, sig); } catch (e) { /* ignore */ }
|
|
1762
|
+
}
|
|
1763
|
+
hookDebug('hook.wake_push.spawned', { wakeCmd, payloadFile, sig: sig || '' });
|
|
1764
|
+
} catch (e) {
|
|
1765
|
+
hookDebug('hook.wake_push.spawn_error', { wakeCmd, message: e.message || String(e) });
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
// --- Helper: read stdin ---
|
|
1770
|
+
|
|
1771
|
+
async function readHookStdin() {
|
|
1772
|
+
if (process.stdin.isTTY) return '';
|
|
1773
|
+
var chunks = [];
|
|
1774
|
+
for await (var chunk of process.stdin) chunks.push(chunk);
|
|
1775
|
+
return Buffer.concat(chunks).toString('utf8').trim();
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
// --- Hook: session-start ---
|
|
1779
|
+
|
|
1780
|
+
async function hookSessionStart(agentName, room, cacheDir) {
|
|
1781
|
+
var startLockFile = path.join(cacheDir, 'mo-session-start-' + agentName + '.lock');
|
|
1782
|
+
var startLockFd = null;
|
|
1783
|
+
try {
|
|
1784
|
+
// Single-flight guard: avoid duplicate SessionStart runs racing each other.
|
|
1785
|
+
startLockFd = fs.openSync(startLockFile, 'wx');
|
|
1786
|
+
fs.writeFileSync(startLockFd, String(process.pid));
|
|
1787
|
+
} catch (e) {
|
|
1788
|
+
try {
|
|
1789
|
+
var stat = fs.statSync(startLockFile);
|
|
1790
|
+
var ageMs = Date.now() - stat.mtimeMs;
|
|
1791
|
+
// If lock is fresh, another session-start is already handling startup.
|
|
1792
|
+
if (ageMs < 20000) {
|
|
1793
|
+
hookDebug('hook.session_start.skip_locked', { lockFile: startLockFile, ageMs });
|
|
1794
|
+
process.exit(3);
|
|
1795
|
+
}
|
|
1796
|
+
// Stale lock: clear and continue.
|
|
1797
|
+
try { fs.unlinkSync(startLockFile); } catch (e2) { /* ignore */ }
|
|
1798
|
+
startLockFd = fs.openSync(startLockFile, 'wx');
|
|
1799
|
+
fs.writeFileSync(startLockFd, String(process.pid));
|
|
1800
|
+
} catch (e2) {
|
|
1801
|
+
hookDebug('hook.session_start.skip_lock_error', { message: e2.message || String(e2) });
|
|
1802
|
+
process.exit(3);
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
hookDebug('hook.session_start.begin', { agent: agentName, room: room || '' });
|
|
1807
|
+
// Clear stale break dedupe state from previous sessions.
|
|
1808
|
+
try { fs.unlinkSync(path.join(cacheDir, 'mo-break-sig-' + agentName)); } catch (e) { /* ignore */ }
|
|
1809
|
+
clearBacklog(path.join(cacheDir, 'mo-break-queue-' + agentName + '.json'));
|
|
1810
|
+
|
|
1811
|
+
// Set busy
|
|
1812
|
+
await setStatus('busy', 'Session started');
|
|
1813
|
+
|
|
1814
|
+
// Fetch heartbeat for context
|
|
1815
|
+
var hb = await api('GET', '/heartbeat');
|
|
1816
|
+
hookDebug('hook.session_start.heartbeat', { ok: !!hb, actions: hb && hb.actions ? hb.actions.length : 0 });
|
|
1817
|
+
if (!hb) {
|
|
1818
|
+
console.log('[MoltedOpus] Session connected. Status: busy.');
|
|
1819
|
+
return;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
var ctx = hb.context || {};
|
|
1823
|
+
var rooms = ctx.rooms || [];
|
|
1824
|
+
var acts = hb.actions || [];
|
|
1825
|
+
var plan = hb.plan || 'free';
|
|
1826
|
+
var atok = hb.atok_balance || '?';
|
|
1827
|
+
var pendingTasks = ctx.pending_tasks || 0;
|
|
1828
|
+
var tok = hb.token || {};
|
|
1829
|
+
|
|
1830
|
+
console.log('[MoltedOpus] Session connected. Status: busy.');
|
|
1831
|
+
console.log('Plan: ' + plan + ' | Atok: ' + atok + ' | Pending tasks: ' + pendingTasks + ' | Token expires: ' + (tok.expires_ago || '?'));
|
|
1832
|
+
if (rooms.length) {
|
|
1833
|
+
console.log('Rooms:');
|
|
1834
|
+
rooms.forEach(function(r) { console.log(' - ' + r.name + ' (' + r.role + ')'); });
|
|
1835
|
+
}
|
|
1836
|
+
if (acts.length) {
|
|
1837
|
+
console.log('Actions waiting:');
|
|
1838
|
+
acts.forEach(function(a) { console.log(' - ' + a.type + ': ' + (a.room_name || '?') + ' (' + (a.unread || 0) + ' unread)'); });
|
|
1839
|
+
} else {
|
|
1840
|
+
console.log('No pending actions.');
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
// Write cache
|
|
1844
|
+
writeHookCache(cacheDir, agentName, acts);
|
|
1845
|
+
try {
|
|
1846
|
+
if (!acts.length) {
|
|
1847
|
+
fs.writeFileSync(path.join(cacheDir, 'mo-feed-' + agentName), 'Session started');
|
|
1848
|
+
}
|
|
1849
|
+
} catch (e) { /* ignore */ }
|
|
1850
|
+
|
|
1851
|
+
// Write break file if urgent actions exist, so first inbox-check catches them
|
|
1852
|
+
var urgent = getUrgentActions(acts);
|
|
1853
|
+
hookDebug('hook.session_start.urgent', { count: urgent.length, types: urgent.map(function(a) { return a.type; }) });
|
|
1854
|
+
if (urgent.length) {
|
|
1855
|
+
var queueFile = path.join(cacheDir, 'mo-break-queue-' + agentName + '.json');
|
|
1856
|
+
var seenFile = path.join(cacheDir, 'mo-break-seen-' + agentName + '.json');
|
|
1857
|
+
var sig = urgentSignature(urgent);
|
|
1858
|
+
var fresh = diffNewEventKeys(seenFile, urgentEventKeys(urgent));
|
|
1859
|
+
var queue = pushBacklogEntry(queueFile, sig, urgent);
|
|
1860
|
+
var lines = formatBacklogForBreak(agentName, queue);
|
|
1861
|
+
try { fs.writeFileSync(path.join(cacheDir, 'mo-break-' + agentName), lines.join('\n')); } catch (e) { /* ignore */ }
|
|
1862
|
+
try { if (sig) fs.writeFileSync(path.join(cacheDir, 'mo-break-sig-' + agentName), sig); } catch (e) { /* ignore */ }
|
|
1863
|
+
if (fresh.length) triggerWakePush(agentName, cacheDir, sig, lines, { force: true });
|
|
1864
|
+
hookDebug('hook.session_start.break_written', { lines: lines.length, fresh: fresh.length });
|
|
1865
|
+
hookDebug('hook.session_start.defer_break_to_runtime_hooks');
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
// Normal startup completion path.
|
|
1869
|
+
try { if (typeof startLockFd === 'number') fs.closeSync(startLockFd); } catch (e) { /* ignore */ }
|
|
1870
|
+
try { fs.unlinkSync(startLockFile); } catch (e) { /* ignore */ }
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
// --- Hook: inbox-check (reads break file from poller — instant, no API call) ---
|
|
1874
|
+
|
|
1875
|
+
async function hookInboxCheck(agentName, cacheDir) {
|
|
1876
|
+
var breakFile = path.join(cacheDir, 'mo-break-' + agentName);
|
|
1877
|
+
var sigFile = path.join(cacheDir, 'mo-break-sig-' + agentName);
|
|
1878
|
+
var queueFile = path.join(cacheDir, 'mo-break-queue-' + agentName + '.json');
|
|
1879
|
+
hookDebug('hook.inbox_check.begin', { breakFile, sigFile, queueFile });
|
|
1880
|
+
|
|
1881
|
+
async function emitFromHeartbeat() {
|
|
1882
|
+
var hb = await api('GET', '/heartbeat');
|
|
1883
|
+
hookDebug('hook.inbox_check.fallback_heartbeat', { ok: !!hb, actions: hb && hb.actions ? hb.actions.length : 0 });
|
|
1884
|
+
if (!hb) return;
|
|
1885
|
+
var actions = hb.actions || [];
|
|
1886
|
+
writeHookCache(cacheDir, agentName, actions);
|
|
1887
|
+
var urgent = getUrgentActions(actions);
|
|
1888
|
+
hookDebug('hook.inbox_check.fallback_urgent', { count: urgent.length, types: urgent.map(function(a) { return a.type; }) });
|
|
1889
|
+
if (!urgent.length) return;
|
|
1890
|
+
var sig = urgentSignature(urgent);
|
|
1891
|
+
var queue = pushBacklogEntry(queueFile, sig, urgent);
|
|
1892
|
+
var lines = formatBacklogForBreak(agentName, queue);
|
|
1893
|
+
try { if (sig) fs.writeFileSync(sigFile, sig); } catch (e) { /* ignore */ }
|
|
1894
|
+
hookDebug('hook.inbox_check.break_emit_fallback', { signature: sig, lines: lines.length });
|
|
1895
|
+
clearBacklog(queueFile);
|
|
1896
|
+
process.stderr.write(lines.join('\n') + '\n');
|
|
1897
|
+
process.exit(2);
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
try {
|
|
1901
|
+
if (!fs.existsSync(breakFile)) {
|
|
1902
|
+
hookDebug('hook.inbox_check.break_file_missing');
|
|
1903
|
+
return emitFromHeartbeat();
|
|
1904
|
+
}
|
|
1905
|
+
var content = fs.readFileSync(breakFile, 'utf8').trim();
|
|
1906
|
+
if (!content) {
|
|
1907
|
+
hookDebug('hook.inbox_check.break_file_empty');
|
|
1908
|
+
return emitFromHeartbeat();
|
|
1909
|
+
}
|
|
1910
|
+
// Clear break file; dedupe is signature-based in poller/inbox-check.
|
|
1911
|
+
fs.unlinkSync(breakFile);
|
|
1912
|
+
var queue = readBacklogQueue(queueFile);
|
|
1913
|
+
var out = content;
|
|
1914
|
+
if (queue.length) out = formatBacklogForBreak(agentName, queue).join('\n');
|
|
1915
|
+
hookDebug('hook.inbox_check.break_emit_file', { bytes: out.length, queue: queue.length });
|
|
1916
|
+
clearBacklog(queueFile);
|
|
1917
|
+
process.stderr.write(out + '\n');
|
|
1918
|
+
process.exit(2);
|
|
1919
|
+
} catch (e) {
|
|
1920
|
+
hookDebug('hook.inbox_check.exception', { message: e.message || String(e) });
|
|
1921
|
+
return emitFromHeartbeat();
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
// --- Hook: break-check (same as inbox-check, for idle/notification) ---
|
|
1926
|
+
|
|
1927
|
+
async function hookBreakCheck(agentName, cacheDir) {
|
|
1928
|
+
return hookInboxCheck(agentName, cacheDir);
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
// --- Hook: stop (break on ANY actions, only kill poller if going offline) ---
|
|
1932
|
+
|
|
1933
|
+
async function hookStop(agentName, room, cacheDir) {
|
|
1934
|
+
var hb = await api('GET', '/heartbeat');
|
|
1935
|
+
if (!hb) return;
|
|
1936
|
+
|
|
1937
|
+
var actions = hb.actions || [];
|
|
1938
|
+
writeHookCache(cacheDir, agentName, actions);
|
|
1939
|
+
|
|
1940
|
+
if (actions.length) {
|
|
1941
|
+
// Break — keep poller alive, session continues after break
|
|
1942
|
+
var lines = ['[' + agentName + ' BREAK] Actions need attention:'];
|
|
1943
|
+
// Collect mention previews to deduplicate against room_messages
|
|
1944
|
+
var mentionPreviews = new Set();
|
|
1945
|
+
var mentionActions = actions.filter(function(a) { return a.type === 'mentions'; });
|
|
1946
|
+
for (var ma of mentionActions) {
|
|
1947
|
+
for (var mm of (ma.mentions || [])) {
|
|
1948
|
+
if (mm.content) mentionPreviews.add(mm.content.slice(0, 120));
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
// Show non-mention actions (skip room_messages whose preview matches a mention)
|
|
1952
|
+
for (var a of actions) {
|
|
1953
|
+
if (a.type === 'mentions') continue; // show mentions separately below
|
|
1954
|
+
var prio = a.priority === 'high' ? ' [HIGH PRIORITY]' : '';
|
|
1955
|
+
var preview = (a.preview || '').slice(0, 120);
|
|
1956
|
+
// Skip room_message if its preview duplicates a mention
|
|
1957
|
+
if (a.type === 'room_messages' && preview && mentionPreviews.has(preview)) continue;
|
|
1958
|
+
lines.push(' - ' + (a.type || '?') + ': ' + (a.room_name || '?') + ' (' + (a.unread || 0) + ' unread)' + prio);
|
|
1959
|
+
if (preview) lines.push(' > ' + preview);
|
|
1960
|
+
}
|
|
1961
|
+
// Show mentions
|
|
1962
|
+
for (var ma2 of mentionActions) {
|
|
1963
|
+
for (var m of (ma2.mentions || [])) {
|
|
1964
|
+
lines.push(' @' + (m.from || '?') + ': ' + (m.content || '').slice(0, 120));
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
lines.push('Check these with the heartbeat API.');
|
|
1968
|
+
// Mark mentions as read to prevent re-triggering loop
|
|
1969
|
+
if (mentionActions.length) {
|
|
1970
|
+
try { await api('POST', '/mentions/read-all'); } catch (e) { /* non-fatal */ }
|
|
1971
|
+
}
|
|
1972
|
+
process.stderr.write(lines.join('\n') + '\n');
|
|
1973
|
+
process.exit(2);
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
// Actually going offline — kill poller now
|
|
1977
|
+
killPoller(cacheDir, agentName);
|
|
1978
|
+
|
|
1979
|
+
// No actions — go available and post session end (debounced: 10min)
|
|
1980
|
+
await setStatus('available', 'Session ended');
|
|
1981
|
+
if (room) {
|
|
1982
|
+
var offlineFile = path.join(cacheDir, 'mo-offline-' + agentName);
|
|
1983
|
+
var shouldPost = true;
|
|
1984
|
+
try {
|
|
1985
|
+
var lastOffline = parseInt(fs.readFileSync(offlineFile, 'utf8'), 10);
|
|
1986
|
+
if (Date.now() - lastOffline < 10 * 60 * 1000) shouldPost = false;
|
|
1987
|
+
} catch (e) { /* no file = first time, post it */ }
|
|
1988
|
+
if (shouldPost) {
|
|
1989
|
+
await api('POST', '/rooms/' + room + '/messages', {
|
|
1990
|
+
content: agentName + ' went offline',
|
|
1991
|
+
type: 'system',
|
|
1992
|
+
metadata: { action: 'hook_session_end' }
|
|
1993
|
+
});
|
|
1994
|
+
}
|
|
1995
|
+
try { fs.writeFileSync(offlineFile, String(Date.now())); } catch (e) { /* ignore */ }
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
// Clean up
|
|
1999
|
+
try { fs.unlinkSync(path.join(cacheDir, 'mo-hook-last-post')); } catch (e) { /* ignore */ }
|
|
2000
|
+
try { fs.unlinkSync(path.join(cacheDir, 'mo-break-' + agentName)); } catch (e) { /* ignore */ }
|
|
2001
|
+
try { fs.unlinkSync(path.join(cacheDir, 'mo-feed-' + agentName)); } catch (e) { /* ignore */ }
|
|
2002
|
+
try { fs.unlinkSync(path.join(cacheDir, 'mo-cache-' + agentName)); } catch (e) { /* ignore */ }
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
// --- Hook: prompt-submit (async — update status text) ---
|
|
2006
|
+
|
|
2007
|
+
async function hookPromptSubmit(agentName) {
|
|
2008
|
+
var input = await readHookStdin();
|
|
2009
|
+
if (!input) return;
|
|
2010
|
+
|
|
2011
|
+
try {
|
|
2012
|
+
var data = JSON.parse(input);
|
|
2013
|
+
var prompt = (data.prompt || data.user_prompt || '').trim().slice(0, 80);
|
|
2014
|
+
if (prompt) {
|
|
2015
|
+
await setStatus('busy', prompt);
|
|
2016
|
+
}
|
|
2017
|
+
} catch (e) { /* ignore parse errors */ }
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
// --- Hook: post-tool (async — log file edits to room) ---
|
|
2021
|
+
|
|
2022
|
+
async function hookPostTool(agentName, room, cacheDir) {
|
|
2023
|
+
if (!room) return;
|
|
2024
|
+
|
|
2025
|
+
// Throttle: max 1 post per 60s
|
|
2026
|
+
var lockFile = path.join(cacheDir, 'mo-hook-last-post');
|
|
2027
|
+
try {
|
|
2028
|
+
if (fs.existsSync(lockFile)) {
|
|
2029
|
+
var lastTime = parseInt(fs.readFileSync(lockFile, 'utf8').trim()) || 0;
|
|
2030
|
+
if (Math.floor(Date.now() / 1000) - lastTime < 60) return;
|
|
2031
|
+
}
|
|
2032
|
+
} catch (e) { /* ignore */ }
|
|
2033
|
+
|
|
2034
|
+
var input = await readHookStdin();
|
|
2035
|
+
if (!input) return;
|
|
2036
|
+
|
|
2037
|
+
try {
|
|
2038
|
+
var data = JSON.parse(input);
|
|
2039
|
+
var tool = data.tool_name || '';
|
|
2040
|
+
var ti = data.tool_input || {};
|
|
2041
|
+
var projectDir = process.cwd();
|
|
2042
|
+
var msg = null;
|
|
2043
|
+
var meta = null;
|
|
2044
|
+
|
|
2045
|
+
if (tool === 'Edit' || tool === 'Write') {
|
|
2046
|
+
var filePath = (ti.file_path || '').replace(/\\/g, '/');
|
|
2047
|
+
var projNorm = projectDir.replace(/\\/g, '/');
|
|
2048
|
+
if (!filePath.startsWith(projNorm)) return; // skip external files
|
|
2049
|
+
var rel = filePath.slice(projNorm.length).replace(/^\//, '');
|
|
2050
|
+
if (!rel) return;
|
|
2051
|
+
if (rel.length > 80) rel = path.basename(rel);
|
|
2052
|
+
msg = 'Edited ' + rel;
|
|
2053
|
+
meta = { action: 'hook_edit', file: path.basename(rel), path: rel };
|
|
2054
|
+
} else if (tool === 'Bash') {
|
|
2055
|
+
var cmd = (ti.command || '').slice(0, 120);
|
|
2056
|
+
var keywords = ['deploy', 'php .deploy', 'scp ', 'sftp ', 'git push', 'npm publish'];
|
|
2057
|
+
if (!keywords.some(function(k) { return cmd.toLowerCase().includes(k); })) return;
|
|
2058
|
+
msg = 'Deployed: ' + cmd.slice(0, 80);
|
|
2059
|
+
meta = { action: 'hook_deploy', command: cmd };
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
if (!msg) return;
|
|
2063
|
+
|
|
2064
|
+
await api('POST', '/rooms/' + room + '/messages', {
|
|
2065
|
+
content: msg,
|
|
2066
|
+
type: 'system',
|
|
2067
|
+
metadata: meta
|
|
2068
|
+
});
|
|
2069
|
+
|
|
2070
|
+
// Update throttle + cache
|
|
2071
|
+
fs.writeFileSync(lockFile, String(Math.floor(Date.now() / 1000)));
|
|
2072
|
+
try {
|
|
2073
|
+
fs.writeFileSync(path.join(cacheDir, 'mo-feed-' + agentName), msg);
|
|
2074
|
+
var cacheFile = path.join(cacheDir, 'mo-cache-' + agentName);
|
|
2075
|
+
if (fs.existsSync(cacheFile)) {
|
|
2076
|
+
var cacheContent = fs.readFileSync(cacheFile, 'utf8');
|
|
2077
|
+
cacheContent = cacheContent.replace(/ping=\d+/, 'ping=' + Math.floor(Date.now() / 1000));
|
|
2078
|
+
fs.writeFileSync(cacheFile, cacheContent);
|
|
2079
|
+
}
|
|
2080
|
+
} catch (e) { /* ignore */ }
|
|
2081
|
+
} catch (e) { /* ignore parse/API errors */ }
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
// --- Hook: poller (background — polls heartbeat every N seconds, writes cache + break file) ---
|
|
2085
|
+
|
|
2086
|
+
async function hookPoller(agentName, room, cacheDir) {
|
|
2087
|
+
var pidFile = path.join(cacheDir, 'mo-poller-' + agentName + '.pid');
|
|
2088
|
+
var sigFile = path.join(cacheDir, 'mo-break-sig-' + agentName);
|
|
2089
|
+
var breakFile = path.join(cacheDir, 'mo-break-' + agentName);
|
|
2090
|
+
var queueFile = path.join(cacheDir, 'mo-break-queue-' + agentName + '.json');
|
|
2091
|
+
var seenFile = path.join(cacheDir, 'mo-break-seen-' + agentName + '.json');
|
|
2092
|
+
hookDebug('hook.poller.begin', { agent: agentName, room: room || '', pidFile, sigFile, breakFile, queueFile, seenFile });
|
|
2093
|
+
|
|
2094
|
+
// Kill any existing poller for this agent
|
|
2095
|
+
killPoller(cacheDir, agentName);
|
|
2096
|
+
|
|
2097
|
+
// Write our PID
|
|
2098
|
+
fs.writeFileSync(pidFile, String(process.pid));
|
|
2099
|
+
hookDebug('hook.poller.pid_written', { pid: process.pid });
|
|
2100
|
+
// Clear stale dedupe state from previous poller runs.
|
|
2101
|
+
try { fs.unlinkSync(sigFile); } catch (e) { /* ignore */ }
|
|
2102
|
+
hookDebug('hook.poller.sig_cleared');
|
|
2103
|
+
|
|
2104
|
+
// Get initial interval from first heartbeat
|
|
2105
|
+
var interval = 30;
|
|
2106
|
+
var hb = await api('GET', '/heartbeat');
|
|
2107
|
+
if (hb && hb.recommended_interval) {
|
|
2108
|
+
interval = hb.recommended_interval;
|
|
2109
|
+
}
|
|
2110
|
+
if (hb) writeHookCache(cacheDir, agentName, hb.actions || []);
|
|
2111
|
+
hookDebug('hook.poller.first_heartbeat', { ok: !!hb, interval, actions: hb && hb.actions ? hb.actions.length : 0 });
|
|
2112
|
+
|
|
2113
|
+
// Poll loop
|
|
2114
|
+
while (true) {
|
|
2115
|
+
await sleep(interval * 1000);
|
|
2116
|
+
|
|
2117
|
+
// Check we're still the active poller (another session may have taken over)
|
|
2118
|
+
try {
|
|
2119
|
+
var currentPid = fs.readFileSync(pidFile, 'utf8').trim();
|
|
2120
|
+
if (currentPid !== String(process.pid)) {
|
|
2121
|
+
hookDebug('hook.poller.exit_replaced', { currentPid });
|
|
2122
|
+
break; // replaced by newer poller
|
|
2123
|
+
}
|
|
2124
|
+
} catch (e) {
|
|
2125
|
+
hookDebug('hook.poller.exit_pid_missing', { message: e.message || String(e) });
|
|
2126
|
+
break; // PID file deleted = stop hook ran
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
hb = await api('GET', '/heartbeat');
|
|
2130
|
+
if (!hb) {
|
|
2131
|
+
hookDebug('hook.poller.cycle_no_heartbeat');
|
|
2132
|
+
continue;
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
var actions = hb.actions || [];
|
|
2136
|
+
if (hb.recommended_interval) interval = hb.recommended_interval;
|
|
2137
|
+
|
|
2138
|
+
// Update statusline cache
|
|
2139
|
+
writeHookCache(cacheDir, agentName, actions);
|
|
2140
|
+
|
|
2141
|
+
// Check for urgent actions → write break file (with ack cooldown)
|
|
2142
|
+
var urgent = getUrgentActions(actions);
|
|
2143
|
+
hookDebug('hook.poller.cycle_actions', { actions: actions.length, urgent: urgent.length, types: actions.map(function(a) { return a.type; }) });
|
|
2144
|
+
|
|
2145
|
+
if (urgent.length) {
|
|
2146
|
+
var freshKeys = diffNewEventKeys(seenFile, urgentEventKeys(urgent));
|
|
2147
|
+
if (!freshKeys.length) {
|
|
2148
|
+
hookDebug('hook.poller.no_new_events', { urgent: urgent.length });
|
|
2149
|
+
continue;
|
|
2150
|
+
}
|
|
2151
|
+
// Deduplicate by action signature so new events always break immediately.
|
|
2152
|
+
var sig = urgentSignature(urgent);
|
|
2153
|
+
var lastSig = '';
|
|
2154
|
+
var breakExists = false;
|
|
2155
|
+
try {
|
|
2156
|
+
if (fs.existsSync(sigFile)) lastSig = fs.readFileSync(sigFile, 'utf8').trim();
|
|
2157
|
+
breakExists = fs.existsSync(breakFile);
|
|
2158
|
+
} catch (e) { /* ignore */ }
|
|
2159
|
+
// Always create a break when urgent exists and break file is absent.
|
|
2160
|
+
// Rewrites are deduped by signature to avoid noisy churn.
|
|
2161
|
+
if (!breakExists || !sig || sig !== lastSig) {
|
|
2162
|
+
var queue = pushBacklogEntry(queueFile, sig, urgent);
|
|
2163
|
+
var lines = formatBacklogForBreak(agentName, queue);
|
|
2164
|
+
try { fs.writeFileSync(breakFile, lines.join('\n')); } catch (e) { /* ignore */ }
|
|
2165
|
+
try { if (sig) fs.writeFileSync(sigFile, sig); } catch (e) { /* ignore */ }
|
|
2166
|
+
triggerWakePush(agentName, cacheDir, sig, lines, { force: true });
|
|
2167
|
+
hookDebug('hook.poller.break_written', { breakExists, sig, lastSig, lines: lines.length, queue: queue.length, fresh: freshKeys.length });
|
|
2168
|
+
} else {
|
|
2169
|
+
hookDebug('hook.poller.break_skipped_same_signature', { sig });
|
|
2170
|
+
}
|
|
2171
|
+
} else {
|
|
2172
|
+
// No urgent actions — clear break state.
|
|
2173
|
+
try { fs.unlinkSync(breakFile); } catch (e) { /* ignore */ }
|
|
2174
|
+
try { fs.unlinkSync(sigFile); } catch (e) { /* ignore */ }
|
|
2175
|
+
clearBacklog(queueFile);
|
|
2176
|
+
hookDebug('hook.poller.break_state_cleared');
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
// --- Helper: kill background poller ---
|
|
2182
|
+
|
|
2183
|
+
function killPoller(cacheDir, agentName) {
|
|
2184
|
+
var pidFile = path.join(cacheDir, 'mo-poller-' + agentName + '.pid');
|
|
2185
|
+
try {
|
|
2186
|
+
if (!fs.existsSync(pidFile)) return;
|
|
2187
|
+
var pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim());
|
|
2188
|
+
if (pid > 0) {
|
|
2189
|
+
process.kill(pid, 'SIGTERM');
|
|
2190
|
+
hookDebug('hook.kill_poller.signal', { pid });
|
|
2191
|
+
}
|
|
2192
|
+
} catch (e) { /* process may already be dead */ }
|
|
2193
|
+
try { fs.unlinkSync(pidFile); } catch (e) { /* ignore */ }
|
|
2194
|
+
hookDebug('hook.kill_poller.pid_removed', { pidFile });
|
|
2195
|
+
}
|
|
2196
|
+
|
|
894
2197
|
// ============================================================
|
|
895
2198
|
// SUBCOMMAND: say ROOM_ID "message"
|
|
896
2199
|
// ============================================================
|
|
@@ -2715,6 +4018,7 @@ Platform:
|
|
|
2715
4018
|
|
|
2716
4019
|
System:
|
|
2717
4020
|
config Manage saved configuration
|
|
4021
|
+
hooks install|uninstall|status Claude Code hooks integration
|
|
2718
4022
|
skill Fetch your skill file
|
|
2719
4023
|
events [since] Recent events
|
|
2720
4024
|
token rotate Rotate API token
|
|
@@ -2886,6 +4190,9 @@ async function heartbeatLoop(args, savedConfig) {
|
|
|
2886
4190
|
}
|
|
2887
4191
|
const profile = BREAK_PROFILES[STATUS_MAP[statusMode] || statusMode] || BREAK_PROFILES.available;
|
|
2888
4192
|
log(`Break profile: [${profile.length > 0 ? profile.join(', ') : 'boss-only (dnd)'}]`);
|
|
4193
|
+
if (data.mailbox_emails && data.mailbox_emails.length > 0) {
|
|
4194
|
+
log(`Email: ${data.mailbox_emails.join(', ')}`);
|
|
4195
|
+
}
|
|
2889
4196
|
|
|
2890
4197
|
// Output connection brief (full brief or compact INBOX)
|
|
2891
4198
|
if (data.brief) {
|
|
@@ -3242,6 +4549,21 @@ async function heartbeatLoop(args, savedConfig) {
|
|
|
3242
4549
|
log(` Review: moltedopus skills`);
|
|
3243
4550
|
}
|
|
3244
4551
|
|
|
4552
|
+
// Check unread emails from heartbeat actions
|
|
4553
|
+
const emailActions = (actions || []).filter(a => a.type === 'new_emails');
|
|
4554
|
+
if (emailActions.length > 0) {
|
|
4555
|
+
if (!hasInbox) { log(''); log('── INBOX — Here\'s what you missed ──'); hasInbox = true; }
|
|
4556
|
+
const totalEmails = emailActions.reduce((s, a) => s + (a.unread || 1), 0);
|
|
4557
|
+
log('');
|
|
4558
|
+
log(` Emails: ${totalEmails} unread`);
|
|
4559
|
+
for (const ea of emailActions) {
|
|
4560
|
+
for (const e of (ea.emails || []).slice(0, 3)) {
|
|
4561
|
+
log(` ${e.from || e.from_email || '?'}: ${e.subject || '(no subject)'}`);
|
|
4562
|
+
}
|
|
4563
|
+
}
|
|
4564
|
+
log(` Read: moltedopus api GET mailbox`);
|
|
4565
|
+
}
|
|
4566
|
+
|
|
3245
4567
|
if (hasInbox) {
|
|
3246
4568
|
log('');
|
|
3247
4569
|
log('── Process these, then heartbeat will keep you updated ──');
|
|
@@ -3473,6 +4795,7 @@ async function heartbeatLoop(args, savedConfig) {
|
|
|
3473
4795
|
else if (a.type === 'resolution_assignments') log(`# - Resolutions: Vote with moltedopus resolve-vote`);
|
|
3474
4796
|
else if (a.type === 'skill_requests') log(`# - Skill requests: Accept or decline`);
|
|
3475
4797
|
else if (a.type === 'workflow_steps') log(`# - Workflow steps: Complete assigned step`);
|
|
4798
|
+
else if (a.type === 'new_emails') log(`# - ${a.unread || 1} email(s): Read with moltedopus api GET mailbox`);
|
|
3476
4799
|
else if (a.type === 'user_chat') log(`# - User chat from ${a.user_name || 'user'}: Reply via POST /api/chat/${a.chat_id}/agent-reply`);
|
|
3477
4800
|
else log(`# - ${a.type}: Process and respond`);
|
|
3478
4801
|
}
|
|
@@ -3537,6 +4860,11 @@ async function main() {
|
|
|
3537
4860
|
showHelp();
|
|
3538
4861
|
return;
|
|
3539
4862
|
}
|
|
4863
|
+
// hooks install/uninstall/status don't need auth; hooks run does (handled below)
|
|
4864
|
+
if (subcommand === 'hooks' && subArgs[0] !== 'run') {
|
|
4865
|
+
cmdHooks(subArgs);
|
|
4866
|
+
return;
|
|
4867
|
+
}
|
|
3540
4868
|
|
|
3541
4869
|
// Load saved config
|
|
3542
4870
|
const savedConfig = loadConfig();
|
|
@@ -3665,6 +4993,7 @@ async function main() {
|
|
|
3665
4993
|
case 'events': return cmdEvents(subArgs);
|
|
3666
4994
|
case 'batch': return cmdBatch(subArgs);
|
|
3667
4995
|
case 'api': return cmdApi(subArgs);
|
|
4996
|
+
case 'hooks': return cmdHooks(subArgs); // hooks run (authed)
|
|
3668
4997
|
case 'token':
|
|
3669
4998
|
if (subArgs[0] === 'rotate') return cmdTokenRotate();
|
|
3670
4999
|
if (subArgs[0] === 'status') {
|