@@ -11,8 +11,9 @@ import {
1111 type SessionDaemonResponse ,
1212} from "../session/protocol" ;
1313
14- const STALE_SESSION_TTL_MS = 45_000 ;
15- const STALE_SESSION_SWEEP_INTERVAL_MS = 15_000 ;
14+ const DEFAULT_STALE_SESSION_TTL_MS = 45_000 ;
15+ const DEFAULT_STALE_SESSION_SWEEP_INTERVAL_MS = 15_000 ;
16+ const DEFAULT_IDLE_TIMEOUT_MS = 60_000 ;
1617
1718const SUPPORTED_SESSION_ACTIONS : SessionDaemonAction [ ] = [
1819 "list" ,
@@ -26,6 +27,12 @@ const SUPPORTED_SESSION_ACTIONS: SessionDaemonAction[] = [
2627 "comment-clear" ,
2728] ;
2829
30+ export interface ServeHunkMcpServerOptions {
31+ idleTimeoutMs ?: number ;
32+ staleSessionTtlMs ?: number ;
33+ staleSessionSweepIntervalMs ?: number ;
34+ }
35+
2936function formatDaemonServeError ( error : unknown , host : string , port : number ) {
3037 const message = error instanceof Error ? error . message : String ( error ) ;
3138 const normalized = message . toLowerCase ( ) ;
@@ -154,18 +161,90 @@ async function handleSessionApiRequest(state: HunkDaemonState, request: Request)
154161}
155162
156163/** Serve the local Hunk session daemon and websocket session broker. */
157- export function serveHunkMcpServer ( ) {
164+ export function serveHunkMcpServer ( options : ServeHunkMcpServerOptions = { } ) {
158165 const config = resolveHunkMcpConfig ( ) ;
166+ const idleTimeoutMs = options . idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS ;
167+ const staleSessionTtlMs = options . staleSessionTtlMs ?? DEFAULT_STALE_SESSION_TTL_MS ;
168+ const staleSessionSweepIntervalMs =
169+ options . staleSessionSweepIntervalMs ?? DEFAULT_STALE_SESSION_SWEEP_INTERVAL_MS ;
159170 const state = new HunkDaemonState ( ) ;
160171 const startedAt = Date . now ( ) ;
172+ let lastActivityAt = startedAt ;
161173 let shuttingDown = false ;
174+ let sweepTimer : Timer | null = null ;
175+ let idleTimer : Timer | null = null ;
176+ let server : ReturnType < typeof Bun . serve < { } > > | null = null ;
177+
178+ const hasActiveWork = ( ) => state . getSessionCount ( ) > 0 || state . getPendingCommandCount ( ) > 0 ;
179+
180+ const clearIdleShutdownTimer = ( ) => {
181+ if ( ! idleTimer ) {
182+ return ;
183+ }
184+
185+ clearTimeout ( idleTimer ) ;
186+ idleTimer = null ;
187+ } ;
188+
189+ const shutdown = ( ) => {
190+ if ( shuttingDown ) {
191+ return ;
192+ }
193+
194+ shuttingDown = true ;
195+ if ( sweepTimer ) {
196+ clearInterval ( sweepTimer ) ;
197+ sweepTimer = null ;
198+ }
199+
200+ clearIdleShutdownTimer ( ) ;
201+ process . off ( "SIGINT" , shutdown ) ;
202+ process . off ( "SIGTERM" , shutdown ) ;
203+
204+ state . shutdown ( ) ;
205+ server ?. stop ( true ) ;
206+ } ;
207+
208+ const refreshIdleShutdownTimer = ( ) => {
209+ clearIdleShutdownTimer ( ) ;
210+
211+ if ( shuttingDown || idleTimeoutMs <= 0 || hasActiveWork ( ) ) {
212+ return ;
213+ }
214+
215+ const idleForMs = Date . now ( ) - lastActivityAt ;
216+ const remainingMs = Math . max ( 0 , idleTimeoutMs - idleForMs ) ;
162217
163- const sweepTimer = setInterval ( ( ) => {
164- state . pruneStaleSessions ( { ttlMs : STALE_SESSION_TTL_MS } ) ;
165- } , STALE_SESSION_SWEEP_INTERVAL_MS ) ;
218+ idleTimer = setTimeout ( ( ) => {
219+ idleTimer = null ;
220+
221+ if ( shuttingDown || hasActiveWork ( ) ) {
222+ return ;
223+ }
224+
225+ if ( Date . now ( ) - lastActivityAt < idleTimeoutMs ) {
226+ refreshIdleShutdownTimer ( ) ;
227+ return ;
228+ }
229+
230+ shutdown ( ) ;
231+ } , remainingMs ) ;
232+ idleTimer . unref ?.( ) ;
233+ } ;
234+
235+ const noteActivity = ( ) => {
236+ lastActivityAt = Date . now ( ) ;
237+ refreshIdleShutdownTimer ( ) ;
238+ } ;
239+
240+ sweepTimer = setInterval ( ( ) => {
241+ const removed = state . pruneStaleSessions ( { ttlMs : staleSessionTtlMs } ) ;
242+ if ( removed > 0 ) {
243+ noteActivity ( ) ;
244+ }
245+ } , staleSessionSweepIntervalMs ) ;
166246 sweepTimer . unref ?.( ) ;
167247
168- let server : ReturnType < typeof Bun . serve < { } > > ;
169248 try {
170249 server = Bun . serve < { } > ( {
171250 hostname : config . host ,
@@ -174,7 +253,11 @@ export function serveHunkMcpServer() {
174253 const url = new URL ( request . url ) ;
175254
176255 if ( url . pathname === "/health" ) {
177- state . pruneStaleSessions ( { ttlMs : STALE_SESSION_TTL_MS } ) ;
256+ const removed = state . pruneStaleSessions ( { ttlMs : staleSessionTtlMs } ) ;
257+ if ( removed > 0 ) {
258+ noteActivity ( ) ;
259+ }
260+
178261 return Response . json ( {
179262 ok : true ,
180263 pid : process . pid ,
@@ -183,17 +266,19 @@ export function serveHunkMcpServer() {
183266 sessionApi : `${ config . httpOrigin } ${ HUNK_SESSION_API_PATH } ` ,
184267 sessionCapabilities : `${ config . httpOrigin } ${ HUNK_SESSION_CAPABILITIES_PATH } ` ,
185268 sessionSocket : `${ config . wsOrigin } ${ HUNK_SESSION_SOCKET_PATH } ` ,
186- sessions : state . listSessions ( ) . length ,
269+ sessions : state . getSessionCount ( ) ,
187270 pendingCommands : state . getPendingCommandCount ( ) ,
188- staleSessionTtlMs : STALE_SESSION_TTL_MS ,
271+ staleSessionTtlMs,
189272 } ) ;
190273 }
191274
192275 if ( url . pathname === HUNK_SESSION_CAPABILITIES_PATH ) {
276+ noteActivity ( ) ;
193277 return Response . json ( sessionCapabilities ( ) ) ;
194278 }
195279
196280 if ( url . pathname === HUNK_SESSION_API_PATH ) {
281+ noteActivity ( ) ;
197282 return handleSessionApiRequest ( state , request ) ;
198283 }
199284
@@ -230,44 +315,41 @@ export function serveHunkMcpServer() {
230315 switch ( parsed . type ) {
231316 case "register" :
232317 state . registerSession ( socket , parsed . registration , parsed . snapshot ) ;
318+ noteActivity ( ) ;
233319 break ;
234320 case "snapshot" :
235321 state . updateSnapshot ( parsed . sessionId , parsed . snapshot ) ;
322+ noteActivity ( ) ;
236323 break ;
237324 case "heartbeat" :
238325 state . markSessionSeen ( parsed . sessionId ) ;
326+ noteActivity ( ) ;
239327 break ;
240328 case "command-result" :
241329 state . handleCommandResult ( parsed ) ;
330+ noteActivity ( ) ;
242331 break ;
243332 }
244333 } ,
245334 close : ( socket ) => {
246335 state . unregisterSocket ( socket ) ;
336+ noteActivity ( ) ;
247337 } ,
248338 } ,
249339 } ) ;
250340 } catch ( error ) {
251- clearInterval ( sweepTimer ) ;
252- throw formatDaemonServeError ( error , config . host , config . port ) ;
253- }
254-
255- const shutdown = ( ) => {
256- if ( shuttingDown ) {
257- return ;
341+ if ( sweepTimer ) {
342+ clearInterval ( sweepTimer ) ;
343+ sweepTimer = null ;
258344 }
259345
260- shuttingDown = true ;
261- clearInterval ( sweepTimer ) ;
262- process . off ( "SIGINT" , shutdown ) ;
263- process . off ( "SIGTERM" , shutdown ) ;
264-
265- state . shutdown ( ) ;
266- server . stop ( true ) ;
267- } ;
346+ clearIdleShutdownTimer ( ) ;
347+ throw formatDaemonServeError ( error , config . host , config . port ) ;
348+ }
268349
269350 process . once ( "SIGINT" , shutdown ) ;
270351 process . once ( "SIGTERM" , shutdown ) ;
352+ refreshIdleShutdownTimer ( ) ;
271353
272354 console . log ( `Hunk session daemon listening on ${ config . httpOrigin } ${ HUNK_SESSION_API_PATH } ` ) ;
273355 console . log ( `Hunk session websocket listening on ${ config . wsOrigin } ${ HUNK_SESSION_SOCKET_PATH } ` ) ;
0 commit comments