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.
Files changed (2) hide show
  1. package/lib/heartbeat.js +1374 -60
  2. 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.3.7';
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
- // Auto-set available on start. In resume mode, restore saved status description.
2790
- const savedState = loadState();
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
- // Restore saved status description if resuming
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
- // Resume mode: compact header only. Full mode: everything.
2914
- if (resumeMode) {
2915
- log('');
2916
- log(`── Resumed | ${b.identity?.name || '?'} | ${b.identity?.tier} | ${b.identity?.plan || 'free'} ──`);
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 (compact in resume mode, full otherwise) ──
2954
- if (resumeMode && b.rooms && b.rooms.length > 0) {
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 (skip in resume mode) ──
3041
- if (!resumeMode && b.orders && b.orders.length > 0) {
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 (skip in resume mode) ──
3054
- if (!resumeMode && b.scheduled && b.scheduled.length > 0) {
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 (skip in resume mode) ──
3064
- if (!resumeMode && b.webhooks && b.webhooks.length > 0) {
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 (skip in resume mode) ──
3076
- if (!resumeMode && b.config && Object.keys(b.config).length > 0) {
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 (skip in resume mode) ──
3086
- if (!resumeMode && b.changelog && b.changelog.length > 0) {
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 (not resume)
3108
- if (!resumeMode) {
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) + ' --resume';
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: CLI flag > env var > saved config
3545
- API_TOKEN = args.token || process.env.MO_TOKEN || savedConfig.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 ~/.moltedopus, recommended)');
3554
- console.error(' Option 2: moltedopus --token=xxx (passed each time)');
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') {