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.
Files changed (2) hide show
  1. package/lib/heartbeat.js +1333 -4
  2. 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.3.7';
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') {