@@ -63,6 +63,82 @@ function parseTodo(content) {
6363 }
6464}
6565
66+ function hasReplyLogEntry ( replyLogContent , threadTs ) {
67+ const lines = replyLogContent . split ( "\n" ) ;
68+ for ( const line of lines ) {
69+ const trimmed = line . trim ( ) ;
70+ if ( ! trimmed ) continue ;
71+ try {
72+ const entry = JSON . parse ( trimmed ) ;
73+ if ( entry ?. thread_ts === threadTs ) return true ;
74+ } catch {
75+ // Ignore malformed JSONL lines.
76+ }
77+ }
78+ return false ;
79+ }
80+
81+ function hasOutboundSendCommand ( sessionJsonlContent , threadTs ) {
82+ const escapedThreadTs = threadTs . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, "\\$&" ) ;
83+ const threadTsPattern = new RegExp ( `["']thread_ts["']\\s*:\\s*["']${ escapedThreadTs } ["']` ) ;
84+
85+ for ( const line of sessionJsonlContent . split ( "\n" ) ) {
86+ const trimmed = line . trim ( ) ;
87+ if ( ! trimmed ) continue ;
88+
89+ let parsed ;
90+ try {
91+ parsed = JSON . parse ( trimmed ) ;
92+ } catch {
93+ continue ;
94+ }
95+
96+ if ( parsed ?. type !== "message" ) continue ;
97+ if ( parsed ?. message ?. role !== "assistant" ) continue ;
98+ const items = parsed ?. message ?. content ;
99+ if ( ! Array . isArray ( items ) ) continue ;
100+
101+ for ( const item of items ) {
102+ if ( item ?. type !== "toolCall" ) continue ;
103+ if ( item ?. name !== "bash" ) continue ;
104+ const command = typeof item ?. arguments ?. command === "string" ? item . arguments . command : "" ;
105+ if ( ! command . includes ( "curl" ) ) continue ;
106+ if ( ! command . includes ( "/send" ) ) continue ;
107+ if ( ! threadTsPattern . test ( command ) ) continue ;
108+ return true ;
109+ }
110+ }
111+
112+ return false ;
113+ }
114+
115+ function slackTsToMs ( ts ) {
116+ const parsed = Number . parseFloat ( ts ) ;
117+ if ( ! Number . isFinite ( parsed ) || parsed <= 0 ) return null ;
118+ return Math . floor ( parsed * 1000 ) ;
119+ }
120+
121+ function extractMentionThreadTs ( logTail ) {
122+ const mentionThreadTs = new Set ( ) ;
123+
124+ for ( const line of logTail . split ( "\n" ) ) {
125+ if ( ! line . includes ( "app_mention" ) ) continue ;
126+
127+ const threadMatch = line . match ( / \b t h r e a d _ t s : \s * ( \d + \. \d + ) / ) ;
128+ if ( threadMatch ?. [ 1 ] ) {
129+ mentionThreadTs . add ( threadMatch [ 1 ] ) ;
130+ continue ;
131+ }
132+
133+ const tsMatch = line . match ( / \b t s : \s * ( \d + \. \d + ) / ) ;
134+ if ( tsMatch ?. [ 1 ] ) {
135+ mentionThreadTs . add ( tsMatch [ 1 ] ) ;
136+ }
137+ }
138+
139+ return [ ...mentionThreadTs ] ;
140+ }
141+
66142// ── Test helpers ────────────────────────────────────────────────────────────
67143
68144// ── Tests ───────────────────────────────────────────────────────────────────
@@ -312,6 +388,95 @@ Not part of JSON.`;
312388 } ) ;
313389} ) ;
314390
391+ describe ( "heartbeat v2: unanswered mention log parsing" , ( ) => {
392+ it ( "extracts app_mention ts from broker-bridge log format" , ( ) => {
393+ const log =
394+ "[2026-02-28T21:10:00.000Z] 👤 message from <@U123> in C123 (type: app_mention, thread_ts: 1772313000.000001, ts: 1772313000.123456)" ;
395+ assert . deepEqual ( extractMentionThreadTs ( log ) , [ "1772313000.000001" ] ) ;
396+ } ) ;
397+
398+ it ( "falls back to message ts when thread_ts is absent" , ( ) => {
399+ const log = "[2026-02-28T21:10:00.000Z] 👤 message from <@U123> in C123 (type: app_mention, ts: 1772313000.123456)" ;
400+ assert . deepEqual ( extractMentionThreadTs ( log ) , [ "1772313000.123456" ] ) ;
401+ } ) ;
402+
403+ it ( "extracts app_mention ts from socket-mode bridge log format" , ( ) => {
404+ const log = "📣 app_mention from <@U123> in C123 ts: 1772313001.654321" ;
405+ assert . deepEqual ( extractMentionThreadTs ( log ) , [ "1772313001.654321" ] ) ;
406+ } ) ;
407+
408+ it ( "prefers thread_ts over message ts when both are present" , ( ) => {
409+ const log =
410+ "📣 app_mention from <@U123> in C123 thread_ts: 1772313000.000001 ts: 1772313001.654321" ;
411+ assert . deepEqual ( extractMentionThreadTs ( log ) , [ "1772313000.000001" ] ) ;
412+ } ) ;
413+
414+ it ( "ignores non-app_mention log lines" , ( ) => {
415+ const log = [
416+ "💬 from <@U123>: hello" ,
417+ "[2026-02-28T21:10:00.000Z] 👤 message from <@U123> in C123 (type: message, ts: 1772313000.123456)" ,
418+ "🧵 Registered thread-1 → channel=C123 thread_ts=1772313000.123456" ,
419+ ] . join ( "\n" ) ;
420+ assert . deepEqual ( extractMentionThreadTs ( log ) , [ ] ) ;
421+ } ) ;
422+
423+ it ( "converts slack ts to milliseconds" , ( ) => {
424+ assert . equal ( slackTsToMs ( "1772313000.123456" ) , 1772313000123 ) ;
425+ assert . equal ( slackTsToMs ( "0" ) , null ) ;
426+ assert . equal ( slackTsToMs ( "not-a-ts" ) , null ) ;
427+ } ) ;
428+ } ) ;
429+
430+ describe ( "heartbeat v2: unanswered mention reply detection" , ( ) => {
431+ it ( "matches exact thread_ts entries in reply log jsonl" , ( ) => {
432+ const log = [
433+ '{"thread_ts":"1234.5678","replied_at":"2026-02-27T00:00:00Z"}' ,
434+ '{"thread_ts":"2345.6789","replied_at":"2026-02-27T00:05:00Z"}' ,
435+ ] . join ( "\n" ) ;
436+
437+ assert . equal ( hasReplyLogEntry ( log , "1234.5678" ) , true ) ;
438+ assert . equal ( hasReplyLogEntry ( log , "9999.0000" ) , false ) ;
439+ } ) ;
440+
441+ it ( "ignores malformed reply-log lines" , ( ) => {
442+ const log = [ '{"thread_ts":"1234.5678"}' , 'not-json' , '{"thread_ts":"2345.6789"}' ] . join ( "\n" ) ;
443+ assert . equal ( hasReplyLogEntry ( log , "2345.6789" ) , true ) ;
444+ } ) ;
445+
446+ it ( "detects outbound curl /send with matching thread_ts" , ( ) => {
447+ const session = JSON . stringify ( {
448+ type : "message" ,
449+ message : {
450+ role : "assistant" ,
451+ content : [
452+ {
453+ type : "toolCall" ,
454+ name : "bash" ,
455+ arguments : {
456+ command :
457+ "curl -s -X POST http://127.0.0.1:7890/send -H 'Content-Type: application/json' -d '{\"channel\":\"C123\",\"text\":\"hi\",\"thread_ts\":\"1234.5678\"}'" ,
458+ } ,
459+ } ,
460+ ] ,
461+ } ,
462+ } ) ;
463+
464+ assert . equal ( hasOutboundSendCommand ( session , "1234.5678" ) , true ) ;
465+ } ) ;
466+
467+ it ( "does not treat inbound text containing thread_ts as a reply" , ( ) => {
468+ const inboundOnly = JSON . stringify ( {
469+ type : "message" ,
470+ message : {
471+ role : "user" ,
472+ content : [ { type : "text" , text : "inbound event metadata: thread_ts=1234.5678" } ] ,
473+ } ,
474+ } ) ;
475+
476+ assert . equal ( hasOutboundSendCommand ( inboundOnly , "1234.5678" ) , false ) ;
477+ } ) ;
478+ } ) ;
479+
315480describe ( "heartbeat v2: hasMatchingInProgressTodo logic" , ( ) => {
316481 // Replicate the matching logic from the extension
317482 function matchesWorktree ( content , worktreeName ) {
0 commit comments