11import type { AppliedCommentResult , HunkSessionRegistration , HunkSessionSnapshot , SessionClientMessage , SessionServerMessage } from "./types" ;
22import { HUNK_SESSION_SOCKET_PATH , resolveHunkMcpConfig } from "./config" ;
3- import { isHunkDaemonHealthy , launchHunkDaemon , waitForHunkDaemonHealth } from "./daemonLauncher" ;
3+ import { isHunkDaemonHealthy , isLoopbackPortReachable , launchHunkDaemon , waitForHunkDaemonHealth } from "./daemonLauncher" ;
4+
5+ const DAEMON_LAUNCH_COOLDOWN_MS = 5_000 ;
6+ const DAEMON_STARTUP_TIMEOUT_MS = 3_000 ;
7+ const RECONNECT_DELAY_MS = 3_000 ;
8+ const HEARTBEAT_INTERVAL_MS = 10_000 ;
49
510export interface HunkAppBridge {
611 applyComment : ( message : Extract < SessionServerMessage , { command : "comment" } > ) => Promise < AppliedCommentResult > ;
@@ -12,9 +17,11 @@ export class HunkHostClient {
1217 private bridge : HunkAppBridge | null = null ;
1318 private queuedMessages : SessionServerMessage [ ] = [ ] ;
1419 private reconnectTimer : Timer | null = null ;
20+ private heartbeatTimer : Timer | null = null ;
1521 private stopped = false ;
1622 private startupPromise : Promise < void > | null = null ;
1723 private lastDaemonLaunchStartedAt = 0 ;
24+ private lastConnectionWarning : string | null = null ;
1825 private readonly config = resolveHunkMcpConfig ( ) ;
1926
2027 constructor (
@@ -31,9 +38,18 @@ export class HunkHostClient {
3138 return ;
3239 }
3340
34- this . startupPromise = this . ensureDaemonAndConnect ( ) . finally ( ( ) => {
35- this . startupPromise = null ;
36- } ) ;
41+ this . startupPromise = this . ensureDaemonAndConnect ( )
42+ . catch ( ( error ) => {
43+ if ( this . stopped ) {
44+ return ;
45+ }
46+
47+ this . warnUnavailable ( error ) ;
48+ this . scheduleReconnect ( ) ;
49+ } )
50+ . finally ( ( ) => {
51+ this . startupPromise = null ;
52+ } ) ;
3753 }
3854
3955 stop ( ) {
@@ -43,6 +59,7 @@ export class HunkHostClient {
4359 this . reconnectTimer = null ;
4460 }
4561
62+ this . stopHeartbeat ( ) ;
4663 this . websocket ?. close ( ) ;
4764 this . websocket = null ;
4865 }
@@ -54,19 +71,38 @@ export class HunkHostClient {
5471
5572 private async ensureDaemonAvailable ( ) {
5673 if ( await isHunkDaemonHealthy ( this . config ) ) {
74+ this . lastConnectionWarning = null ;
5775 return ;
5876 }
5977
60- const launchCooldownMs = 5_000 ;
61- if ( Date . now ( ) - this . lastDaemonLaunchStartedAt < launchCooldownMs ) {
62- return ;
78+ const shouldLaunch = Date . now ( ) - this . lastDaemonLaunchStartedAt >= DAEMON_LAUNCH_COOLDOWN_MS ;
79+ if ( shouldLaunch ) {
80+ this . lastDaemonLaunchStartedAt = Date . now ( ) ;
81+ launchHunkDaemon ( ) ;
6382 }
6483
65- this . lastDaemonLaunchStartedAt = Date . now ( ) ;
66- launchHunkDaemon ( ) ;
67- await waitForHunkDaemonHealth ( {
84+ const ready = await waitForHunkDaemonHealth ( {
6885 config : this . config ,
86+ timeoutMs : shouldLaunch ? DAEMON_STARTUP_TIMEOUT_MS : 1_500 ,
6987 } ) ;
88+
89+ if ( ready ) {
90+ this . lastConnectionWarning = null ;
91+ return ;
92+ }
93+
94+ const portReachable = await isLoopbackPortReachable ( this . config ) ;
95+ if ( portReachable ) {
96+ throw new Error (
97+ `Hunk MCP port ${ this . config . host } :${ this . config . port } is already in use by another process. ` +
98+ `Stop the conflicting process or set HUNK_MCP_PORT to a different loopback port.` ,
99+ ) ;
100+ }
101+
102+ throw new Error (
103+ `Timed out waiting for the Hunk MCP daemon on ${ this . config . host } :${ this . config . port } . ` +
104+ `Hunk will retry in the background.` ,
105+ ) ;
70106 }
71107
72108 setBridge ( bridge : HunkAppBridge | null ) {
@@ -93,6 +129,8 @@ export class HunkHostClient {
93129
94130 websocket . onopen = ( ) => {
95131 this . lastDaemonLaunchStartedAt = 0 ;
132+ this . lastConnectionWarning = null ;
133+ this . startHeartbeat ( ) ;
96134 this . send ( {
97135 type : "register" ,
98136 registration : this . registration ,
@@ -117,7 +155,11 @@ export class HunkHostClient {
117155 } ;
118156
119157 websocket . onclose = ( ) => {
120- this . websocket = null ;
158+ if ( this . websocket === websocket ) {
159+ this . websocket = null ;
160+ }
161+
162+ this . stopHeartbeat ( ) ;
121163 if ( ! this . stopped ) {
122164 this . scheduleReconnect ( ) ;
123165 }
@@ -128,18 +170,41 @@ export class HunkHostClient {
128170 } ;
129171 }
130172
131- private scheduleReconnect ( ) {
173+ private scheduleReconnect ( delayMs = RECONNECT_DELAY_MS ) {
132174 if ( this . reconnectTimer || this . stopped ) {
133175 return ;
134176 }
135177
136178 this . reconnectTimer = setTimeout ( ( ) => {
137179 this . reconnectTimer = null ;
138- void this . ensureDaemonAndConnect ( ) ;
139- } , 3_000 ) ;
180+ this . start ( ) ;
181+ } , delayMs ) ;
140182 this . reconnectTimer . unref ?.( ) ;
141183 }
142184
185+ private startHeartbeat ( ) {
186+ if ( this . heartbeatTimer ) {
187+ return ;
188+ }
189+
190+ this . heartbeatTimer = setInterval ( ( ) => {
191+ this . send ( {
192+ type : "heartbeat" ,
193+ sessionId : this . registration . sessionId ,
194+ } ) ;
195+ } , HEARTBEAT_INTERVAL_MS ) ;
196+ this . heartbeatTimer . unref ?.( ) ;
197+ }
198+
199+ private stopHeartbeat ( ) {
200+ if ( ! this . heartbeatTimer ) {
201+ return ;
202+ }
203+
204+ clearInterval ( this . heartbeatTimer ) ;
205+ this . heartbeatTimer = null ;
206+ }
207+
143208 private send ( message : SessionClientMessage ) {
144209 if ( ! this . websocket || this . websocket . readyState !== WebSocket . OPEN ) {
145210 return ;
@@ -184,4 +249,14 @@ export class HunkHostClient {
184249 await this . handleServerMessage ( message ) ;
185250 }
186251 }
252+
253+ private warnUnavailable ( error : unknown ) {
254+ const message = error instanceof Error ? error . message : "Unknown Hunk MCP connection error." ;
255+ if ( message === this . lastConnectionWarning ) {
256+ return ;
257+ }
258+
259+ this . lastConnectionWarning = message ;
260+ console . error ( `[hunk:mcp] ${ message } ` ) ;
261+ }
187262}
0 commit comments