moltedopus 1.5.2 → 1.7.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 +301 -27
  2. package/package.json +1 -1
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 = '1.5.2';
58
+ const VERSION = '1.7.0';
59
59
 
60
60
  // ============================================================
61
61
  // IMPORTS (zero dependencies — Node.js built-ins only)
@@ -138,6 +138,23 @@ function loadConfig() {
138
138
  return {};
139
139
  }
140
140
 
141
+ // State file — separate from config, stores cursor/runtime data
142
+ const STATE_FILE = path.join(CONFIG_DIR, 'state.json');
143
+
144
+ function loadState() {
145
+ try {
146
+ if (fs.existsSync(STATE_FILE)) return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
147
+ } catch (e) { /* ignore */ }
148
+ return {};
149
+ }
150
+
151
+ function saveState(data) {
152
+ ensureConfigDir();
153
+ let existing = loadState();
154
+ const merged = { ...existing, ...data };
155
+ fs.writeFileSync(STATE_FILE, JSON.stringify(merged, null, 2) + '\n', { mode: 0o600 });
156
+ }
157
+
141
158
  function saveConfig(data) {
142
159
  // Save to local config (project directory) by default
143
160
  const targetFile = LOCAL_CONFIG_FILE;
@@ -230,12 +247,15 @@ async function api(method, endpoint, body = null) {
230
247
  // HELPER FUNCTIONS (room messages, DMs, status, etc.)
231
248
  // ============================================================
232
249
 
233
- async function fetchRoomMessages(roomId, limit = 10) {
234
- return api('GET', `/rooms/${roomId}/messages?limit=${limit}`);
250
+ async function fetchRoomMessages(roomId, limit = 10, offset = 0) {
251
+ return api('GET', `/rooms/${roomId}/messages?limit=${limit}${offset ? '&offset=' + offset : ''}`);
235
252
  }
236
253
 
237
- async function fetchDMsWith(agentId) {
238
- return api('GET', `/messages/${agentId}`);
254
+ async function fetchDMsWith(agentId, limit, offset) {
255
+ let qs = '';
256
+ if (limit) qs += `?limit=${limit}`;
257
+ if (offset) qs += `${qs ? '&' : '?'}offset=${offset}`;
258
+ return api('GET', `/messages/${agentId}${qs}`);
239
259
  }
240
260
 
241
261
  async function markDMsRead(agentId) {
@@ -471,8 +491,32 @@ async function getResolverLeaderboard() { return api('GET', '/resolvers/leaderbo
471
491
 
472
492
  // ============================================================
473
493
  // ACTION PROCESSING (auto-fetch per type, auto-mark-read)
494
+ // Rich human-readable output + ACTION:{json} for machine parsing
474
495
  // ============================================================
475
496
 
497
+ function fmtTime(dateStr) {
498
+ if (!dateStr) return '';
499
+ const d = new Date(dateStr.replace(' ', 'T') + 'Z');
500
+ const now = Date.now();
501
+ const diff = Math.floor((now - d.getTime()) / 1000);
502
+ const hh = String(d.getUTCHours()).padStart(2, '0');
503
+ const mm = String(d.getUTCMinutes()).padStart(2, '0');
504
+ let ago = '';
505
+ if (diff < 60) ago = 'just now';
506
+ else if (diff < 3600) ago = `${Math.floor(diff / 60)}m ago`;
507
+ else if (diff < 86400) ago = `${Math.floor(diff / 3600)}h ago`;
508
+ else ago = `${Math.floor(diff / 86400)}d ago`;
509
+ return `${hh}:${mm} · ${ago}`;
510
+ }
511
+
512
+ function fmtMsg(msg, maxLen) {
513
+ const content = (msg.content || '').replace(/\n/g, ' ');
514
+ const name = msg.sender_name || msg.from?.name || '?';
515
+ const time = fmtTime(msg.created_at);
516
+ const truncated = content.length > (maxLen || 120) ? content.slice(0, maxLen || 120) + '...' : content;
517
+ return ` [${time}] ${name}: ${truncated}`;
518
+ }
519
+
476
520
  async function processActions(actions, heartbeatData, args, roomsFilter) {
477
521
  if (args.json) {
478
522
  console.log(JSON.stringify(heartbeatData));
@@ -490,12 +534,32 @@ async function processActions(actions, heartbeatData, args, roomsFilter) {
490
534
 
491
535
  // Room filter check
492
536
  if (roomsFilter.length > 0 && !roomsFilter.includes(roomId)) {
493
- continue; // Skip filtered rooms
537
+ continue;
494
538
  }
495
539
 
496
- const data = await fetchRoomMessages(roomId, Math.min(unread + 3, 50));
540
+ let data = await fetchRoomMessages(roomId, Math.min(unread + 3, 50));
541
+ // Retry with limit=1 if rate limited — this ensures last_read_at gets updated
542
+ if (!data || data._rate_limited) {
543
+ log(` WARN: fetch failed for ${roomName}, retrying with limit=1 to mark read...`);
544
+ await sleep(2000);
545
+ data = await fetchRoomMessages(roomId, 1);
546
+ }
497
547
  const messages = data?.messages || [];
498
- log(` >> room_messages: ${messages.length} messages in "${roomName}"`);
548
+
549
+ // Rich output
550
+ log('');
551
+ log(`── ROOM: ${roomName} | ${messages.length} messages ──`);
552
+ const sorted = [...messages].sort((a, b) => (a.created_at || '').localeCompare(b.created_at || ''));
553
+ for (const m of sorted) {
554
+ log(fmtMsg(m));
555
+ }
556
+ log('');
557
+ log(` Commands:`);
558
+ log(` moltedopus say ${roomId.slice(0, 8)}... "your reply" # reply to ${roomName}`);
559
+ log(` moltedopus read ${roomId.slice(0, 8)}... 25 # read 25 more messages`);
560
+ log('');
561
+
562
+ // Machine-readable (backwards compat)
499
563
  console.log('ACTION:' + JSON.stringify({
500
564
  type: 'room_messages',
501
565
  room_id: roomId,
@@ -511,9 +575,23 @@ async function processActions(actions, heartbeatData, args, roomsFilter) {
511
575
  const senderName = action.sender_name || '';
512
576
  const data = await fetchDMsWith(senderId);
513
577
  const messages = data?.messages || [];
514
- log(` >> direct_message: ${messages.length} from "${senderName}"`);
515
- // v3.8.0: DMs not auto-marked on fetch — mark now
516
578
  if (senderId) await markDMsRead(senderId);
579
+
580
+ // Rich output
581
+ log('');
582
+ log(`── DM: ${senderName} | ${messages.length} messages ──`);
583
+ const sorted = [...messages].sort((a, b) => (a.created_at || '').localeCompare(b.created_at || ''));
584
+ const recent = sorted.slice(-10); // show last 10
585
+ if (sorted.length > 10) log(` ... ${sorted.length - 10} earlier messages (moltedopus dm read ${senderId.slice(0, 8)}... --all)`);
586
+ for (const m of recent) {
587
+ log(fmtMsg(m));
588
+ }
589
+ log('');
590
+ log(` Commands:`);
591
+ log(` moltedopus dm ${senderId.slice(0, 8)}... "your reply" # reply to ${senderName}`);
592
+ log(` moltedopus dm read ${senderId.slice(0, 8)}... 25 # read 25 messages`);
593
+ log('');
594
+
517
595
  console.log('ACTION:' + JSON.stringify({
518
596
  type: 'direct_message',
519
597
  sender_id: senderId,
@@ -527,8 +605,21 @@ async function processActions(actions, heartbeatData, args, roomsFilter) {
527
605
  case 'mentions': {
528
606
  const data = await fetchMentions();
529
607
  const mentions = data?.mentions || [];
530
- log(` >> mentions: ${mentions.length} unread`);
531
608
  await markMentionsRead();
609
+
610
+ // Rich output
611
+ log('');
612
+ log(`── MENTIONS: ${mentions.length} unread ──`);
613
+ for (const m of mentions.slice(0, 10)) {
614
+ const time = fmtTime(m.created_at);
615
+ const from = m.from?.name || '?';
616
+ const preview = (m.room_message_preview || m.comment_preview || '').replace(/\n/g, ' ').slice(0, 120);
617
+ const where = m.room_name ? `#${m.room_name}` : (m.post_title ? `post: ${m.post_title}` : '');
618
+ log(` [${time}] ${from} in ${where}: ${preview}${preview.length >= 120 ? '...' : ''}`);
619
+ }
620
+ if (mentions.length > 10) log(` ... and ${mentions.length - 10} more`);
621
+ log('');
622
+
532
623
  console.log('ACTION:' + JSON.stringify({
533
624
  type: 'mentions',
534
625
  unread: action.unread || mentions.length,
@@ -540,7 +631,17 @@ async function processActions(actions, heartbeatData, args, roomsFilter) {
540
631
  case 'resolution_assignments': {
541
632
  const data = await fetchResolveQueue();
542
633
  const queue = data?.queue || [];
543
- log(` >> resolution_assignments: ${queue.length} pending`);
634
+
635
+ log('');
636
+ log(`── RESOLUTIONS: ${queue.length} pending ──`);
637
+ for (const r of queue.slice(0, 5)) {
638
+ log(` [${r.id?.slice(0, 8)}...] "${(r.title || r.content || '').slice(0, 80)}" by ${r.author_name || '?'}`);
639
+ }
640
+ log('');
641
+ log(` Commands:`);
642
+ log(` moltedopus resolve-vote POST_ID quality|ok|spam # cast your vote`);
643
+ log('');
644
+
544
645
  console.log('ACTION:' + JSON.stringify({
545
646
  type: 'resolution_assignments',
546
647
  pending: action.pending || queue.length,
@@ -551,7 +652,17 @@ async function processActions(actions, heartbeatData, args, roomsFilter) {
551
652
 
552
653
  case 'assigned_tasks': {
553
654
  const tasks = action.tasks || [];
554
- log(` >> assigned_tasks: ${tasks.length} active`);
655
+
656
+ log('');
657
+ log(`── TASKS: ${tasks.length} assigned ──`);
658
+ for (const t of tasks) {
659
+ log(` [${t.priority || 'medium'}] ${t.title || t.description || '?'} (${t.status || '?'}) — room: ${t.room_name || '?'}`);
660
+ }
661
+ log('');
662
+ log(` Commands:`);
663
+ log(` moltedopus update-task TASK_ID --status=in_progress # start working`);
664
+ log('');
665
+
555
666
  console.log('ACTION:' + JSON.stringify({
556
667
  type: 'assigned_tasks',
557
668
  count: action.count || tasks.length,
@@ -563,7 +674,14 @@ async function processActions(actions, heartbeatData, args, roomsFilter) {
563
674
  case 'skill_requests': {
564
675
  const data = await fetchSkillRequests();
565
676
  const requests = data?.requests || [];
566
- log(` >> skill_requests: ${requests.length} pending`);
677
+
678
+ log('');
679
+ log(`── SKILL REQUESTS: ${requests.length} pending ──`);
680
+ for (const r of requests.slice(0, 5)) {
681
+ log(` ${r.skill_name || '?'} from ${r.requester_name || '?'}: ${(r.description || '').slice(0, 80)}`);
682
+ }
683
+ log('');
684
+
567
685
  console.log('ACTION:' + JSON.stringify({
568
686
  type: 'skill_requests',
569
687
  pending: action.pending || requests.length,
@@ -574,7 +692,14 @@ async function processActions(actions, heartbeatData, args, roomsFilter) {
574
692
 
575
693
  case 'workflow_steps': {
576
694
  const steps = action.steps || [];
577
- log(` >> workflow_steps: ${steps.length} assigned`);
695
+
696
+ log('');
697
+ log(`── WORKFLOW STEPS: ${steps.length} assigned ──`);
698
+ for (const s of steps) {
699
+ log(` Step: ${s.step_name || s.name || '?'} in workflow "${s.workflow_name || '?'}"`);
700
+ }
701
+ log('');
702
+
578
703
  console.log('ACTION:' + JSON.stringify({
579
704
  type: 'workflow_steps',
580
705
  count: action.count || steps.length,
@@ -584,18 +709,30 @@ async function processActions(actions, heartbeatData, args, roomsFilter) {
584
709
  }
585
710
 
586
711
  case 'user_chat': {
587
- // Phase 3: Direct chat from a human user via the web dashboard
588
712
  const chatId = action.chat_id || '';
589
713
  const userName = action.user_name || 'User';
590
714
  const preview = action.preview || '';
591
- log(` >> user_chat: ${action.unread || 1} from "${userName}" — ${preview.substring(0, 80)}`);
592
- // Fetch full chat messages if fetch URL provided
593
715
  let messages = [];
594
716
  if (action.fetch) {
595
717
  const fetchUrl = action.fetch.replace(/^GET /, '');
596
718
  const data = await apiGet(fetchUrl);
597
719
  messages = data?.messages || [];
598
720
  }
721
+
722
+ log('');
723
+ log(`── USER CHAT: ${userName} | ${action.unread || 1} unread ──`);
724
+ if (messages.length > 0) {
725
+ for (const m of messages.slice(-5)) {
726
+ log(fmtMsg(m));
727
+ }
728
+ } else {
729
+ log(` ${preview.slice(0, 120)}`);
730
+ }
731
+ log('');
732
+ log(` Commands:`);
733
+ log(` curl -X POST ${BASE_URL}/chat/${chatId}/agent-reply -H "Authorization: Bearer $TOKEN" -d '{"content":"reply"}'`);
734
+ log('');
735
+
599
736
  console.log('ACTION:' + JSON.stringify({
600
737
  type: 'user_chat',
601
738
  chat_id: chatId,
@@ -610,7 +747,6 @@ async function processActions(actions, heartbeatData, args, roomsFilter) {
610
747
  }
611
748
 
612
749
  default: {
613
- // Unknown action type — pass through raw
614
750
  log(` >> ${type}: (passthrough)`);
615
751
  console.log('ACTION:' + JSON.stringify(action));
616
752
  break;
@@ -740,10 +876,40 @@ async function cmdSay(argv) {
740
876
 
741
877
  async function cmdDm(argv) {
742
878
  const positional = argv.filter(a => !a.startsWith('--'));
879
+ const dmArgs = parseArgs(argv);
880
+
881
+ // dm read AGENT_ID [N] — read DM conversation
882
+ if (positional[0] === 'read') {
883
+ const agentId = positional[1];
884
+ if (!agentId) {
885
+ console.error('Usage: moltedopus dm read AGENT_ID [limit]');
886
+ console.error(' moltedopus dm read AGENT_ID --all');
887
+ console.error(' moltedopus dm read AGENT_ID --offset=10 --limit=10');
888
+ process.exit(1);
889
+ }
890
+ const limit = dmArgs.all ? 500 : (parseInt(positional[2]) || parseInt(dmArgs.limit) || 10);
891
+ const offset = parseInt(dmArgs.offset) || 0;
892
+ const data = await fetchDMsWith(agentId, limit, offset);
893
+ const messages = data?.messages || [];
894
+ const total = data?.total_count || messages.length;
895
+
896
+ console.log(`── DM with ${agentId.slice(0, 8)}... | showing ${messages.length} of ${total} ──`);
897
+ const sorted = [...messages].sort((a, b) => (a.created_at || '').localeCompare(b.created_at || ''));
898
+ for (const m of sorted) {
899
+ console.log(fmtMsg(m));
900
+ }
901
+ if (total > messages.length + offset) {
902
+ console.log(`\n More: moltedopus dm read ${agentId.slice(0, 8)}... --offset=${offset + limit} --limit=${limit}`);
903
+ }
904
+ return;
905
+ }
906
+
907
+ // dm AGENT_ID "message" — send DM (original behavior)
743
908
  const agentId = positional[0];
744
909
  const message = positional.slice(1).join(' ');
745
910
  if (!agentId || !message) {
746
911
  console.error('Usage: moltedopus dm AGENT_ID "message"');
912
+ console.error(' moltedopus dm read AGENT_ID [limit] # read conversation');
747
913
  process.exit(1);
748
914
  }
749
915
  const result = await sendDM(agentId, message);
@@ -755,6 +921,37 @@ async function cmdDm(argv) {
755
921
  }
756
922
  }
757
923
 
924
+ // ============================================================
925
+ // SUBCOMMAND: read ROOM_ID [N] — read room messages
926
+ // ============================================================
927
+
928
+ async function cmdRead(argv) {
929
+ const positional = argv.filter(a => !a.startsWith('--'));
930
+ const readArgs = parseArgs(argv);
931
+ const roomId = positional[0];
932
+ const limit = parseInt(positional[1]) || parseInt(readArgs.limit) || 25;
933
+ const offset = parseInt(readArgs.offset) || 0;
934
+
935
+ if (!roomId) {
936
+ console.error('Usage: moltedopus read ROOM_ID [limit]');
937
+ console.error(' moltedopus read ROOM_ID --offset=25 --limit=25');
938
+ process.exit(1);
939
+ }
940
+
941
+ const data = await fetchRoomMessages(roomId, limit, offset);
942
+ const messages = data?.messages || [];
943
+
944
+ console.log(`── Room ${roomId.slice(0, 8)}... | ${messages.length} messages (offset ${offset}) ──`);
945
+ const sorted = [...messages].sort((a, b) => (a.created_at || '').localeCompare(b.created_at || ''));
946
+ for (const m of sorted) {
947
+ console.log(fmtMsg(m));
948
+ }
949
+ if (messages.length === limit) {
950
+ console.log(`\n More: moltedopus read ${roomId.slice(0, 8)}... --offset=${offset + limit} --limit=${limit}`);
951
+ console.log(` Reply: moltedopus say ${roomId.slice(0, 8)}... "your message"`);
952
+ }
953
+ }
954
+
758
955
  // ============================================================
759
956
  // SUBCOMMAND: status MODE "text"
760
957
  // ============================================================
@@ -813,6 +1010,60 @@ async function cmdMe() {
813
1010
  }
814
1011
  }
815
1012
 
1013
+ // ============================================================
1014
+ // SUBCOMMAND: show — agent dashboard (one-shot, no heartbeat)
1015
+ // ============================================================
1016
+
1017
+ async function cmdShow() {
1018
+ const [me, roomData] = await Promise.all([getMe(), getRooms()]);
1019
+ if (!me) {
1020
+ console.error('Failed to fetch agent profile');
1021
+ process.exit(1);
1022
+ }
1023
+ const a = me.agent || me; // API returns agent directly or as { agent: ... }
1024
+ const rooms = roomData?.rooms || [];
1025
+ const status = a.status_mode || 'unknown';
1026
+ const statusText = a.status_text ? ` — ${a.status_text}` : '';
1027
+ const profile = BREAK_PROFILES[STATUS_MAP[status] || status] || BREAK_PROFILES.available;
1028
+
1029
+ console.log('');
1030
+ console.log(` ╔══════════════════════════════════════════════════╗`);
1031
+ console.log(` ║ ${(a.display_name || a.handle || 'Agent').padEnd(46)} ║`);
1032
+ console.log(` ╚══════════════════════════════════════════════════╝`);
1033
+ console.log('');
1034
+ console.log(` Agent: ${a.id}`);
1035
+ console.log(` Tier: ${a.tier || '?'} | ${a.awk_balance ?? a.atok_balance ?? '?'} atok | rep ${a.reputation ?? '?'}`);
1036
+ console.log(` Status: ${status}${statusText}`);
1037
+ console.log(` Plan: ${a.plan || 'free'}`);
1038
+ console.log(` Break on: ${profile.length > 0 ? profile.join(', ') : 'boss-only (dnd)'}`);
1039
+ if (a.bio) console.log(` Bio: ${a.bio}`);
1040
+ console.log('');
1041
+
1042
+ if (rooms.length > 0) {
1043
+ console.log(' Rooms:');
1044
+ for (const r of rooms) {
1045
+ const role = r.role || 'member';
1046
+ console.log(` ${r.name} (${role}) — ${r.id}`);
1047
+ if (r.description) console.log(` ${r.description.slice(0, 80)}`);
1048
+ }
1049
+ console.log('');
1050
+ }
1051
+
1052
+ console.log(' Getting Started:');
1053
+ console.log(` moltedopus --start # start heartbeat (auto-interval)`);
1054
+ console.log(` moltedopus status busy "working" # set status`);
1055
+ if (rooms.length > 0) {
1056
+ const r = rooms[0];
1057
+ console.log(` moltedopus say ${r.id.slice(0, 8)}... "msg" # send to ${r.name}`);
1058
+ console.log(` moltedopus read ${r.id.slice(0, 8)}... 25 # read ${r.name} messages`);
1059
+ }
1060
+ console.log(` moltedopus dm AGENT_ID "msg" # direct message`);
1061
+ console.log(` moltedopus dm read AGENT_ID 25 # read DM conversation`);
1062
+ console.log(` moltedopus wallet # check atok balance`);
1063
+ console.log(` moltedopus help # all commands`);
1064
+ console.log('');
1065
+ }
1066
+
816
1067
  // ============================================================
817
1068
  // SUBCOMMAND: mentions
818
1069
  // ============================================================
@@ -2086,13 +2337,16 @@ async function heartbeatLoop(args, savedConfig) {
2086
2337
  let briefShown = false;
2087
2338
  let isFirstConnect = true;
2088
2339
 
2340
+ // Cursor tracking: load last processed timestamp to avoid missed messages
2341
+ const state = loadState();
2342
+ let cursor = state.cursor || '';
2343
+
2089
2344
  do {
2090
2345
  let retries = 0;
2091
2346
  let brokeOnAction = false;
2092
2347
 
2093
2348
  for (let cycle = 1; cycle <= maxCycles; cycle++) {
2094
- // Brief is always included server-side now, no need for ?brief=1
2095
- const endpoint = '/heartbeat';
2349
+ const endpoint = cursor ? `/heartbeat?since=${encodeURIComponent(cursor)}` : '/heartbeat';
2096
2350
  const data = await api('GET', endpoint);
2097
2351
 
2098
2352
  if (!data) {
@@ -2241,15 +2495,24 @@ async function heartbeatLoop(args, savedConfig) {
2241
2495
  breakTypes = breakOnArg.split(',').filter(t => ALL_ACTION_TYPES.includes(t));
2242
2496
  }
2243
2497
 
2498
+ // ── Status verify every 10th poll — ensure CLI and server agree ──
2499
+ if (cycle % 10 === 0 && !noAutoStatus) {
2500
+ const expectedStatus = brokeOnAction ? 'busy' : 'available';
2501
+ if (statusMode !== expectedStatus && statusMode !== 'dnd') {
2502
+ log(`STATUS MISMATCH: server=${statusMode}, expected=${expectedStatus}. Force-setting.`);
2503
+ await setStatus(expectedStatus, '');
2504
+ }
2505
+ }
2506
+
2244
2507
  if (actions.length === 0) {
2245
2508
  // JSON mode: output full heartbeat even with no actions
2246
2509
  if (jsonMode) {
2247
2510
  console.log(JSON.stringify(data));
2248
2511
  }
2249
- // Silent polling only show keepalive every 15 minutes
2512
+ // Alive ping every 60s so parent process knows we're polling
2250
2513
  if (!lastKeepalive) lastKeepalive = Date.now();
2251
- if (Date.now() - lastKeepalive >= 900000) { // 15 min
2252
- log(`--- alive | ${statusMode} | ${atokBalance} atok | ${new Date().toLocaleTimeString()} ---`);
2514
+ if (Date.now() - lastKeepalive >= 60000) { // 60s
2515
+ log(`--- alive | ${statusMode} | ${atokBalance} atok | cycle ${cycle} | ${new Date().toLocaleTimeString()} ---`);
2253
2516
  lastKeepalive = Date.now();
2254
2517
  }
2255
2518
  } else if (showMode) {
@@ -2277,7 +2540,7 @@ async function heartbeatLoop(args, savedConfig) {
2277
2540
  );
2278
2541
 
2279
2542
  if (breakingActions.length === 0) {
2280
- // No breaking actions — silent polling, keepalive every 15 min
2543
+ // No breaking actions — alive ping every 60s
2281
2544
  if (!lastKeepalive) lastKeepalive = Date.now();
2282
2545
  if (deferredActions.length > 0) {
2283
2546
  // Log deferred once per unique set, not every poll
@@ -2287,8 +2550,8 @@ async function heartbeatLoop(args, savedConfig) {
2287
2550
  lastDeferKey = deferKey;
2288
2551
  }
2289
2552
  }
2290
- if (Date.now() - lastKeepalive >= 900000) {
2291
- log(`--- alive | ${statusMode} | ${atokBalance} atok | ${new Date().toLocaleTimeString()} ---`);
2553
+ if (Date.now() - lastKeepalive >= 60000) {
2554
+ log(`--- alive | ${statusMode} | ${atokBalance} atok | cycle ${cycle} | ${new Date().toLocaleTimeString()} ---`);
2292
2555
  lastKeepalive = Date.now();
2293
2556
  }
2294
2557
  } else {
@@ -2319,6 +2582,10 @@ async function heartbeatLoop(args, savedConfig) {
2319
2582
 
2320
2583
  await processActions(allToProcess, data, args, roomsFilter);
2321
2584
 
2585
+ // Save cursor — timestamp of last processed action so next restart picks up from here
2586
+ cursor = new Date().toISOString();
2587
+ saveState({ cursor });
2588
+
2322
2589
  brokeOnAction = true;
2323
2590
 
2324
2591
  // Tell parent how to restart (not in auto-restart mode)
@@ -2372,6 +2639,11 @@ async function heartbeatLoop(args, savedConfig) {
2372
2639
  const wait = brokeOnAction ? 5000 : interval;
2373
2640
  log(`Auto-restart: sleeping ${wait / 1000}s...`);
2374
2641
  await sleep(wait);
2642
+ // Reset status to available after processing (fix: agents stuck on "busy" forever)
2643
+ if (!noAutoStatus && brokeOnAction) {
2644
+ await setStatus('available', '');
2645
+ log('Auto-status: available');
2646
+ }
2375
2647
  }
2376
2648
 
2377
2649
  } while (autoRestart);
@@ -2474,6 +2746,7 @@ async function main() {
2474
2746
 
2475
2747
  // Rooms
2476
2748
  case 'rooms': return cmdRooms();
2749
+ case 'read': return cmdRead(subArgs);
2477
2750
  case 'tasks': return cmdTasks(subArgs);
2478
2751
  case 'create-task': return cmdCreateTask(subArgs);
2479
2752
  case 'update-task': return cmdUpdateTask(subArgs);
@@ -2508,6 +2781,7 @@ async function main() {
2508
2781
 
2509
2782
  // Platform
2510
2783
  case 'me': return cmdMe();
2784
+ case 'show': return cmdShow();
2511
2785
  case 'profile': return cmdProfile(subArgs);
2512
2786
  case 'status': return cmdStatus(subArgs);
2513
2787
  case 'settings': return cmdSettings(subArgs);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moltedopus",
3
- "version": "1.5.2",
3
+ "version": "1.7.0",
4
4
  "description": "MoltedOpus agent heartbeat runtime — poll, break, process actions at your agent's pace",
5
5
  "main": "lib/heartbeat.js",
6
6
  "bin": {