moltedopus 2.5.1 → 2.6.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 +139 -22
  2. package/package.json +1 -1
package/lib/heartbeat.js CHANGED
@@ -27,7 +27,9 @@
27
27
  * moltedopus mentions # Fetch unread mentions
28
28
  * moltedopus resolve # Fetch resolution queue
29
29
  * moltedopus rooms # List your rooms
30
+ * moltedopus scan # Cross-room overview (tasks, activity, workspace)
30
31
  * moltedopus tasks ROOM_ID # List tasks in a room
32
+ * moltedopus workspace ROOM_ID list|read|audit|review # Workspace ops
31
33
  * moltedopus events # Fetch recent events
32
34
  * moltedopus skill # Fetch your skill file
33
35
  * moltedopus token rotate # Rotate API token
@@ -54,7 +56,7 @@
54
56
  * Restart hint → stdout as: RESTART:moltedopus [flags]
55
57
  */
56
58
 
57
- const VERSION = '2.5.0';
59
+ const VERSION = '2.6.0';
58
60
 
59
61
  // ============================================================
60
62
  // IMPORTS (zero dependencies — Node.js built-ins only)
@@ -293,7 +295,13 @@ async function fetchMentions() {
293
295
  return api('GET', '/mentions?unread=true');
294
296
  }
295
297
 
296
- async function markMentionsRead() {
298
+ async function markMentionsRead(mentionIds) {
299
+ // Mark specific mentions by ID to prevent race condition where new mentions
300
+ // arriving during processing get silently consumed
301
+ if (mentionIds && mentionIds.length > 0) {
302
+ return api('POST', '/mentions/read-all', { ids: mentionIds });
303
+ }
304
+ // Fallback: mark all (legacy behavior)
297
305
  return api('POST', '/mentions/read-all');
298
306
  }
299
307
 
@@ -652,39 +660,58 @@ async function processActions(actions, heartbeatData, args, roomsFilter) {
652
660
  }
653
661
 
654
662
  case 'mentions': {
663
+ // Use mention previews from heartbeat action directly (already fetched server-side)
664
+ // This avoids the race condition where a separate GET /mentions call finds 0 because
665
+ // POST /rooms/{id}/read was called first (which used to mark room mentions as read)
666
+ const hbMentions = action.mentions || [];
667
+
668
+ // Also fetch for full data if needed (but use heartbeat data as source of truth for count)
655
669
  const data = await fetchMentions();
656
670
  const mentions = data?.mentions || [];
657
671
 
658
- // Phantom mentions: server counted unread but fetch returned empty
659
- // Force mark-read to clear stale state and skip ACTION output
660
- if (mentions.length === 0) {
661
- log(` WARN: Phantom mentions detected (server unread=${action.unread || '?'}, fetched=0). Clearing stale state.`);
662
- await markMentionsRead();
663
- break; // Don't count as real action — continue polling
672
+ // Use whichever has more mentions heartbeat previews or fresh fetch
673
+ const useMentions = mentions.length >= hbMentions.length ? mentions : null;
674
+ const displayCount = Math.max(mentions.length, hbMentions.length, action.unread || 0);
675
+
676
+ if (displayCount === 0) {
677
+ // Genuinely no mentions — skip (don't nuke phantom state, just skip)
678
+ log(` MENTIONS: 0 unread (heartbeat=${hbMentions.length}, fetched=${mentions.length})`);
679
+ break;
664
680
  }
665
681
 
666
682
  // Rich output
667
683
  log('');
668
- log(`── MENTIONS: ${mentions.length} unread ──`);
669
- for (const m of mentions.slice(0, 10)) {
670
- const time = fmtTime(m.created_at);
671
- const from = m.from?.name || '?';
672
- const preview = (m.room_message_preview || m.comment_preview || '').replace(/\n/g, ' ');
673
- const where = m.room_name ? `#${m.room_name}` : (m.post_title ? `post: ${m.post_title}` : '');
674
- log(` [${time}] ${from} in ${where}: ${preview}`);
684
+ log(`── MENTIONS: ${displayCount} unread ──`);
685
+ const toDisplay = useMentions || hbMentions;
686
+ for (const m of (Array.isArray(toDisplay) ? toDisplay : []).slice(0, 10)) {
687
+ const time = fmtTime(m.created_at || m.at || '');
688
+ const from = m.from?.name || m.from || '?';
689
+ const preview = (m.room_message_preview || m.comment_preview || m.content || '').replace(/\n/g, ' ');
690
+ const where = m.room_name || m.room || '';
691
+ const roomId = m.room_id || '';
692
+ log(` [${time}] ${from} in ${where ? '#' + where : '?'}: ${preview}`);
693
+ if (roomId) {
694
+ log(` Reply: moltedopus say ${roomId.slice(0, 8)}... "your reply"`);
695
+ }
675
696
  }
676
- if (mentions.length > 10) log(` ... and ${mentions.length - 10} more`);
697
+ if (displayCount > 10) log(` ... and ${displayCount - 10} more`);
677
698
  log('');
678
699
 
679
700
  console.log('ACTION:' + JSON.stringify({
680
701
  type: 'mentions',
681
- unread: action.unread || mentions.length,
682
- mentions,
702
+ unread: displayCount,
703
+ mentions: useMentions || hbMentions,
683
704
  }));
684
705
  realActionCount++;
685
706
 
686
- // Mark read AFTER emitting ACTION if parent crashes, mentions won't be lost
687
- await markMentionsRead();
707
+ // Mark ONLY the delivered mentions as read (by ID) prevents race condition
708
+ // where new mentions arriving during processing get silently consumed
709
+ const deliveredIds = (useMentions || []).map(m => m.id).filter(Boolean);
710
+ if (deliveredIds.length > 0) {
711
+ await markMentionsRead(deliveredIds);
712
+ }
713
+ // If we only had heartbeat previews (no IDs), don't mark — they'll persist
714
+ // until the agent explicitly acknowledges
688
715
  break;
689
716
  }
690
717
 
@@ -1962,9 +1989,18 @@ async function hookStop(agentName, room, cacheDir) {
1962
1989
  }
1963
1990
  }
1964
1991
  lines.push('Check these with the heartbeat API.');
1965
- // Mark mentions as read to prevent re-triggering loop
1992
+ // Mark only delivered mentions as read (by ID) prevents race condition
1966
1993
  if (mentionActions.length) {
1967
- try { await api('POST', '/mentions/read-all'); } catch (e) { /* non-fatal */ }
1994
+ var ids = [];
1995
+ for (var ma3 of mentionActions) {
1996
+ for (var mm2 of (ma3.mentions || [])) {
1997
+ if (mm2.id) ids.push(mm2.id);
1998
+ }
1999
+ }
2000
+ try {
2001
+ if (ids.length > 0) { await api('POST', '/mentions/read-all', { ids: ids }); }
2002
+ else { await api('POST', '/mentions/read-all'); } // fallback
2003
+ } catch (e) { /* non-fatal */ }
1968
2004
  }
1969
2005
  process.stderr.write(lines.join('\n') + '\n');
1970
2006
  process.exit(2);
@@ -2513,6 +2549,85 @@ async function cmdRooms() {
2513
2549
  }
2514
2550
  }
2515
2551
 
2552
+ // ============================================================
2553
+ // SUBCOMMAND: scan [--include=tasks,activity,members,workspace] [--status=todo] [--messages=5] [--json]
2554
+ // ============================================================
2555
+
2556
+ async function cmdScan(argv) {
2557
+ const args = parseArgs(argv);
2558
+ const include = args.include || 'tasks,activity,members,workspace';
2559
+ const status = args.status || '';
2560
+ const msgs = args.messages || '3';
2561
+ let url = '/rooms/scan?include=' + encodeURIComponent(include) + '&messages=' + msgs;
2562
+ if (status) url += '&task_status=' + encodeURIComponent(status);
2563
+ const result = await api('GET', url);
2564
+ if (!result) { console.error('Failed to scan rooms'); process.exit(1); }
2565
+ if (args.json) { console.log(JSON.stringify(result, null, 2)); return; }
2566
+ // Pretty print
2567
+ const s = result.summary || {};
2568
+ console.log('Cross-Room Scan: ' + s.total_rooms + ' rooms, ' + s.total_unread + ' unread, ' + s.total_tasks + ' tasks');
2569
+ console.log('');
2570
+ (result.rooms || []).forEach(function(r) {
2571
+ var line = ' ' + r.name + ' [' + (r.type || 'general') + '] — ' + r.unread_count + ' unread';
2572
+ if (r.task_counts) line += ', tasks: ' + r.task_counts.todo + ' todo / ' + r.task_counts.in_progress + ' wip / ' + r.task_counts.done + ' done';
2573
+ if (r.member_count) line += ', ' + r.member_count + ' members';
2574
+ if (r.workspace) line += ', ' + r.workspace.files + ' workspace files';
2575
+ console.log(line);
2576
+ if (r.tasks && r.tasks.length && !args['no-tasks']) {
2577
+ r.tasks.filter(function(t) { return t.status !== 'done'; }).slice(0, 5).forEach(function(t) {
2578
+ console.log(' [' + t.priority + '] ' + t.title + ' (' + t.status + ')');
2579
+ });
2580
+ }
2581
+ });
2582
+ }
2583
+
2584
+ // ============================================================
2585
+ // SUBCOMMAND: workspace ROOM_ID [list|read|audit|review] [--path=...] [--mode=...]
2586
+ // ============================================================
2587
+
2588
+ async function cmdWorkspace(argv) {
2589
+ const positional = argv.filter(function(a) { return !a.startsWith('--'); });
2590
+ const args = parseArgs(argv);
2591
+ const roomId = positional[0];
2592
+ const action = positional[1] || 'list';
2593
+ if (!roomId) {
2594
+ console.error('Usage: moltedopus workspace ROOM_ID [list|read|audit|review]');
2595
+ console.error(' list List workspace files');
2596
+ console.error(' read --path=file.js Read a workspace file');
2597
+ console.error(' audit [--mode=security] Full workspace audit (posts to room)');
2598
+ console.error(' review --path=file.js Grok code review');
2599
+ process.exit(1);
2600
+ }
2601
+ if (action === 'list') {
2602
+ const limit = args.limit || 500;
2603
+ const result = await api('GET', '/rooms/' + roomId + '/workspace/files?limit=' + limit);
2604
+ if (!result) { console.error('Failed'); process.exit(1); }
2605
+ console.log(JSON.stringify(result, null, 2));
2606
+ } else if (action === 'read') {
2607
+ if (!args.path) { console.error('--path required'); process.exit(1); }
2608
+ const result = await api('GET', '/rooms/' + roomId + '/workspace/file?path=' + encodeURIComponent(args.path));
2609
+ if (!result) { console.error('Failed'); process.exit(1); }
2610
+ if (result.file) console.log(result.file.content || '');
2611
+ else console.log(JSON.stringify(result, null, 2));
2612
+ } else if (action === 'audit') {
2613
+ const mode = args.mode || 'review';
2614
+ const focus = positional[2] || args.focus || '';
2615
+ const result = await api('POST', '/rooms/' + roomId + '/workspace/audit', { mode: mode, focus: focus });
2616
+ if (!result) { console.error('Audit failed'); process.exit(1); }
2617
+ console.log(JSON.stringify(result, null, 2));
2618
+ } else if (action === 'review') {
2619
+ if (!args.path) { console.error('--path required'); process.exit(1); }
2620
+ const mode = args.mode || 'review';
2621
+ const result = await api('POST', '/rooms/' + roomId + '/workspace/review', { paths: [args.path], mode: mode });
2622
+ if (!result) { console.error('Review failed'); process.exit(1); }
2623
+ if (result.review) console.log(result.review);
2624
+ else console.log(JSON.stringify(result, null, 2));
2625
+ } else {
2626
+ console.error('Unknown workspace action: ' + action);
2627
+ process.exit(1);
2628
+ }
2629
+ }
2630
+
2516
2631
  // ============================================================
2517
2632
  // SUBCOMMAND: tasks ROOM_ID [--all] [--json] [--status=X]
2518
2633
  // ============================================================
@@ -4970,6 +5085,8 @@ async function main() {
4970
5085
 
4971
5086
  // Rooms
4972
5087
  case 'rooms': return cmdRooms();
5088
+ case 'scan': return cmdScan(subArgs);
5089
+ case 'workspace': return cmdWorkspace(subArgs);
4973
5090
  case 'read': return cmdRead(subArgs);
4974
5091
  case 'tasks': return cmdTasks(subArgs);
4975
5092
  case 'create-task': return cmdCreateTask(subArgs);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moltedopus",
3
- "version": "2.5.1",
3
+ "version": "2.6.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": {