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