@@ -70,6 +70,7 @@ import {
7070 getSupportedProviders ,
7171} from '@agent-relay/daemon' ;
7272import { HealthWorkerManager , getHealthPort } from './services/health-worker-manager.js' ;
73+ import { MessageBuffer } from './messageBuffer.js' ;
7374
7475/**
7576 * Get the host to bind to.
@@ -655,8 +656,11 @@ export async function startDashboard(
655656 } ;
656657
657658 // Initialize spawner if enabled
658- // Use detectWorkspacePath to find the actual repo directory in cloud workspaces
659- const workspacePath = detectWorkspacePath ( projectRoot || dataDir ) ;
659+ // When projectRoot is explicitly provided (e.g., via --project-root), use it directly.
660+ // Only use detectWorkspacePath for cloud workspace auto-detection when no explicit root is given.
661+ // This fixes #380: detectWorkspacePath could re-resolve projectRoot incorrectly when
662+ // tool directories like ~/.nvm contain package.json markers.
663+ const workspacePath = projectRoot || detectWorkspacePath ( dataDir ) ;
660664 console . log ( `[dashboard] Workspace path: ${ workspacePath } ` ) ;
661665
662666 // When an external SpawnManager is provided (from the daemon), use it for read operations
@@ -769,16 +773,32 @@ export async function startDashboard(
769773 const fileWatchers = new Map < string , fs . FSWatcher > ( ) ;
770774 const fileLastSize = new Map < string , number > ( ) ;
771775
776+ // Message buffers for replay on reconnect
777+ // Main buffer stores broadcast messages for the main dashboard WebSocket
778+ const mainMessageBuffer = new MessageBuffer ( 500 ) ;
779+ // Per-agent log buffers store log output for each agent (smaller capacity since per-agent)
780+ const agentLogBuffers = new Map < string , MessageBuffer > ( ) ;
781+
782+ /** Get or create a log buffer for an agent */
783+ const getAgentLogBuffer = ( agentName : string ) : MessageBuffer => {
784+ let buffer = agentLogBuffers . get ( agentName ) ;
785+ if ( ! buffer ) {
786+ buffer = new MessageBuffer ( 200 ) ;
787+ agentLogBuffers . set ( agentName , buffer ) ;
788+ }
789+ return buffer ;
790+ } ;
791+
772792 // Track alive status for ping/pong keepalive on main dashboard connections
773793 // This prevents TCP/proxy timeouts from killing idle workspace connections
774794 const mainClientAlive = new WeakMap < WebSocket , boolean > ( ) ;
775795
776796 // Track alive status for ping/pong keepalive on bridge connections
777797 const bridgeClientAlive = new WeakMap < WebSocket , boolean > ( ) ;
778798
779- // Ping interval for main dashboard WebSocket connections (30 seconds)
780- // Aligns with heartbeat timeout (5s heartbeat * 6 multiplier = 30s)
781- const MAIN_PING_INTERVAL_MS = 30000 ;
799+ // Ping interval for main dashboard WebSocket connections (15 seconds)
800+ // Reduced from 30s to detect disconnects faster and minimize message loss window
801+ const MAIN_PING_INTERVAL_MS = 15000 ;
782802 const mainPingInterval = setInterval ( ( ) => {
783803 wss . clients . forEach ( ( ws ) => {
784804 if ( mainClientAlive . get ( ws ) === false ) {
@@ -793,8 +813,9 @@ export async function startDashboard(
793813 } ) ;
794814 } , MAIN_PING_INTERVAL_MS ) ;
795815
796- // Ping interval for bridge WebSocket connections (30 seconds)
797- const BRIDGE_PING_INTERVAL_MS = 30000 ;
816+ // Ping interval for bridge WebSocket connections (15 seconds)
817+ // Reduced from 30s to detect disconnects faster and minimize message loss window
818+ const BRIDGE_PING_INTERVAL_MS = 15000 ;
798819 const bridgePingInterval = setInterval ( ( ) => {
799820 wssBridge . clients . forEach ( ( ws ) => {
800821 if ( bridgeClientAlive . get ( ws ) === false ) {
@@ -2141,7 +2162,8 @@ export async function startDashboard(
21412162 }
21422163 // Extract model from spawn command (e.g., "codex --model gpt-5.2-codex" → "gpt-5.2-codex")
21432164 if ( worker . cli ) {
2144- const modelMatch = worker . cli . match ( / - - m o d e l \s + ( \S + ) / ) ;
2165+ // Support both `--model foo` and `--model=foo`
2166+ const modelMatch = worker . cli . match ( / - - m o d e l [ = \s ] + ( \S + ) / ) ;
21452167 if ( modelMatch ) {
21462168 agent . model = modelMatch [ 1 ] ;
21472169 }
@@ -2251,14 +2273,18 @@ export async function startDashboard(
22512273 const broadcastData = async ( ) => {
22522274 try {
22532275 const data = await getAllData ( ) ;
2254- const payload = JSON . stringify ( data ) ;
2276+ const rawPayload = JSON . stringify ( data ) ;
22552277
22562278 // Guard against empty/invalid payloads
2257- if ( ! payload || payload . length === 0 ) {
2279+ if ( ! rawPayload || rawPayload . length === 0 ) {
22582280 console . warn ( '[dashboard] Skipping broadcast - empty payload' ) ;
22592281 return ;
22602282 }
22612283
2284+ // Push into buffer and wrap with sequence ID for replay support
2285+ const seq = mainMessageBuffer . push ( 'data' , rawPayload ) ;
2286+ const payload = JSON . stringify ( { seq, ...data } ) ;
2287+
22622288 wss . clients . forEach ( client => {
22632289 // Skip clients that are still being initialized by the connection handler
22642290 if ( initializingClients . has ( client ) ) {
@@ -2375,6 +2401,11 @@ export async function startDashboard(
23752401 mainClientAlive . set ( ws , true ) ;
23762402 } ) ;
23772403
2404+ // Send current sequence ID so client can track its position
2405+ if ( ws . readyState === WebSocket . OPEN ) {
2406+ ws . send ( JSON . stringify ( { type : 'sync' , sequenceId : mainMessageBuffer . currentId ( ) } ) ) ;
2407+ }
2408+
23782409 // Mark as initializing to prevent broadcastData from sending before we do
23792410 initializingClients . add ( ws ) ;
23802411
@@ -2402,6 +2433,42 @@ export async function startDashboard(
24022433 initializingClients . delete ( ws ) ;
24032434 }
24042435
2436+ // Handle messages from client (replay requests, etc.)
2437+ ws . on ( 'message' , ( data ) => {
2438+ try {
2439+ const msg = JSON . parse ( data . toString ( ) ) ;
2440+
2441+ // Handle replay request: client sends { type: "replay", lastSequenceId: N }
2442+ if ( msg . type === 'replay' && typeof msg . lastSequenceId === 'number' ) {
2443+ const missed = mainMessageBuffer . getAfter ( msg . lastSequenceId ) ;
2444+ const gapMs = missed . length > 0 ? Date . now ( ) - missed [ 0 ] . timestamp : 0 ;
2445+
2446+ console . log ( `[dashboard] Client replaying ${ missed . length } missed messages (gap: ${ gapMs } ms)` ) ;
2447+
2448+ // Send each missed message with its original sequence ID
2449+ for ( const buffered of missed ) {
2450+ if ( ws . readyState === WebSocket . OPEN ) {
2451+ try {
2452+ // Reconstruct the payload with the seq wrapper
2453+ const original = JSON . parse ( buffered . payload ) ;
2454+ ws . send ( JSON . stringify ( { seq : buffered . id , ...original } ) ) ;
2455+ } catch ( err ) {
2456+ console . error ( '[dashboard] Failed to replay message:' , err ) ;
2457+ }
2458+ }
2459+ }
2460+
2461+ // Send current sync position after replay
2462+ if ( ws . readyState === WebSocket . OPEN ) {
2463+ ws . send ( JSON . stringify ( { type : 'sync' , sequenceId : mainMessageBuffer . currentId ( ) } ) ) ;
2464+ }
2465+ }
2466+ } catch ( err ) {
2467+ // Non-JSON messages are ignored (binary, etc.)
2468+ debug ( `[dashboard] Unhandled main WebSocket message: ${ err } ` ) ;
2469+ }
2470+ } ) ;
2471+
24052472 ws . on ( 'error' , ( err ) => {
24062473 console . error ( '[dashboard] WebSocket client error:' , err ) ;
24072474 } ) ;
@@ -2445,9 +2512,9 @@ export async function startDashboard(
24452512 // Track alive status for ping/pong keepalive on log connections
24462513 const logClientAlive = new WeakMap < WebSocket , boolean > ( ) ;
24472514
2448- // Ping interval for log WebSocket connections (30 seconds)
2449- // This prevents TCP/proxy timeouts from killing idle connections
2450- const LOG_PING_INTERVAL_MS = 30000 ;
2515+ // Ping interval for log WebSocket connections (15 seconds)
2516+ // Reduced from 30s to detect disconnects faster and minimize message loss window
2517+ const LOG_PING_INTERVAL_MS = 15000 ;
24512518 const logPingInterval = setInterval ( ( ) => {
24522519 wssLogs . clients . forEach ( ( ws ) => {
24532520 if ( logClientAlive . get ( ws ) === false ) {
@@ -2480,6 +2547,11 @@ export async function startDashboard(
24802547 logClientAlive . set ( ws , true ) ;
24812548 } ) ;
24822549
2550+ // Send sync message with current server timestamp so client can track its position
2551+ if ( ws . readyState === WebSocket . OPEN ) {
2552+ ws . send ( JSON . stringify ( { type : 'sync' , serverTimestamp : Date . now ( ) } ) ) ;
2553+ }
2554+
24832555 // Helper to check if agent is daemon-connected (from agents.json)
24842556 const isDaemonConnected = ( agentName : string ) : boolean => {
24852557 const agentsPath = path . join ( teamDir , 'agents.json' ) ;
@@ -2572,6 +2644,9 @@ export async function startDashboard(
25722644 timestamp : new Date ( ) . toISOString ( ) ,
25732645 } ) ;
25742646
2647+ // Push into per-agent log buffer for replay on reconnect
2648+ getAgentLogBuffer ( agentName ) . push ( 'output' , payload ) ;
2649+
25752650 for ( const client of clients ) {
25762651 if ( client . readyState === WebSocket . OPEN ) {
25772652 client . send ( payload ) ;
@@ -2763,6 +2838,31 @@ export async function startDashboard(
27632838 } ) ) ;
27642839 }
27652840 }
2841+
2842+ // Handle replay request: client sends { type: "replay", agent: "name", lastTimestamp: N }
2843+ // Logs use timestamps instead of sequence IDs since the data is raw text
2844+ if ( msg . type === 'replay' && typeof msg . agent === 'string' && typeof msg . lastTimestamp === 'number' ) {
2845+ const logBuffer = agentLogBuffers . get ( msg . agent ) ;
2846+ if ( logBuffer ) {
2847+ const missed = logBuffer . getAfterTimestamp ( msg . lastTimestamp ) ;
2848+ const gapMs = missed . length > 0 ? Date . now ( ) - missed [ 0 ] . timestamp : 0 ;
2849+
2850+ console . log ( `[dashboard] Client replaying ${ missed . length } missed log messages for ${ msg . agent } (gap: ${ gapMs } ms)` ) ;
2851+
2852+ // Send replay as a structured response the client expects
2853+ if ( ws . readyState === WebSocket . OPEN ) {
2854+ try {
2855+ const entries = missed . map ( m => ( {
2856+ content : m . payload ,
2857+ timestamp : m . timestamp ,
2858+ } ) ) ;
2859+ ws . send ( JSON . stringify ( { type : 'replay' , entries } ) ) ;
2860+ } catch ( err ) {
2861+ console . error ( '[dashboard] Failed to replay log messages:' , err ) ;
2862+ }
2863+ }
2864+ }
2865+ }
27662866 } catch ( err ) {
27672867 console . error ( '[dashboard] Invalid logs WebSocket message:' , err ) ;
27682868 }
@@ -2784,6 +2884,7 @@ export async function startDashboard(
27842884 watcher . close ( ) ;
27852885 fileWatchers . delete ( agentName ) ;
27862886 fileLastSize . delete ( agentName ) ;
2887+ agentLogBuffers . delete ( agentName ) ;
27872888 console . log ( `[dashboard] Stopped watching log file for: ${ agentName } ` ) ;
27882889 }
27892890 }
@@ -2843,12 +2944,17 @@ export async function startDashboard(
28432944 }
28442945 }
28452946
2846- const payload = JSON . stringify ( {
2947+ const logPayload = {
28472948 type : 'output' ,
28482949 agent : agentName ,
28492950 data : output ,
28502951 timestamp : new Date ( ) . toISOString ( ) ,
2851- } ) ;
2952+ } ;
2953+ const payload = JSON . stringify ( logPayload ) ;
2954+
2955+ // Push into per-agent log buffer for replay on reconnect
2956+ // Logs use timestamps instead of sequence IDs since the data is raw text
2957+ getAgentLogBuffer ( agentName ) . push ( 'output' , payload ) ;
28522958
28532959 for ( const client of clients ) {
28542960 if ( client . readyState === WebSocket . OPEN ) {
@@ -2886,7 +2992,10 @@ export async function startDashboard(
28862992 mentions ?: string [ ] ;
28872993 timestamp : string ;
28882994 } ) => {
2889- const payload = JSON . stringify ( message ) ;
2995+ // Push into buffer and wrap with sequence ID for replay support
2996+ const rawPayload = JSON . stringify ( message ) ;
2997+ const seq = mainMessageBuffer . push ( 'channel_message' , rawPayload ) ;
2998+ const payload = JSON . stringify ( { seq, ...message } ) ;
28902999 // Broadcast to main WebSocket clients (local mode)
28913000 wss . clients . forEach ( ( client ) => {
28923001 if ( client . readyState === WebSocket . OPEN ) {
@@ -2914,7 +3023,10 @@ export async function startDashboard(
29143023 messageId : string ;
29153024 timestamp : string ;
29163025 } ) => {
2917- const payload = JSON . stringify ( message ) ;
3026+ // Push into buffer and wrap with sequence ID for replay support
3027+ const rawPayload = JSON . stringify ( message ) ;
3028+ const seq = mainMessageBuffer . push ( 'direct_message' , rawPayload ) ;
3029+ const payload = JSON . stringify ( { seq, ...message } ) ;
29183030
29193031 // Broadcast to main WebSocket clients (local mode)
29203032 const mainClients = Array . from ( wss . clients ) . filter ( c => c . readyState === WebSocket . OPEN ) ;
0 commit comments