@@ -11,7 +11,7 @@ import type {
1111 SessionNavigateCommandInput ,
1212 SessionSelectorInput ,
1313} from "../core/types" ;
14- import { isHunkDaemonHealthy , isLoopbackPortReachable } from "../mcp/daemonLauncher" ;
14+ import { isHunkDaemonHealthy , isLoopbackPortReachable , launchHunkDaemon , waitForHunkDaemonHealth } from "../mcp/daemonLauncher" ;
1515import { resolveHunkMcpConfig } from "../mcp/config" ;
1616import type {
1717 AppliedCommentResult ,
@@ -23,9 +23,10 @@ import type {
2323 SessionLiveCommentSummary ,
2424} from "../mcp/types" ;
2525
26- interface HunkDaemonCliClient {
26+ export interface HunkDaemonCliClient {
2727 connect ( ) : Promise < void > ;
2828 close ( ) : Promise < void > ;
29+ listToolNames ( ) : Promise < Set < string > > ;
2930 listSessions ( ) : Promise < ListedSession [ ] > ;
3031 getSession ( selector : SessionSelectorInput ) : Promise < ListedSession > ;
3132 getSelectedContext ( selector : SessionSelectorInput ) : Promise < SelectedSessionContext > ;
@@ -36,17 +37,46 @@ interface HunkDaemonCliClient {
3637 clearComments ( input : SessionCommentClearCommandInput ) : Promise < ClearedCommentsResult > ;
3738}
3839
40+ const REQUIRED_TOOLS_BY_ACTION : Partial < Record < SessionCommandInput [ "action" ] , string [ ] > > = {
41+ context : [ "get_selected_context" ] ,
42+ navigate : [ "navigate_to_hunk" ] ,
43+ "comment-list" : [ "list_comments" ] ,
44+ "comment-rm" : [ "remove_comment" ] ,
45+ "comment-clear" : [ "clear_comments" ] ,
46+ } ;
47+
48+ interface SessionCommandTestHooks {
49+ createClient ?: ( ) => HunkDaemonCliClient ;
50+ resolveDaemonAvailability ?: ( action : SessionCommandInput [ "action" ] ) => Promise < boolean > ;
51+ restartDaemonForMissingTools ?: ( missingTools : string [ ] , selector ?: SessionSelectorInput ) => Promise < void > ;
52+ }
53+
54+ let sessionCommandTestHooks : SessionCommandTestHooks | null = null ;
55+
56+ export function setSessionCommandTestHooks ( hooks : SessionCommandTestHooks | null ) {
57+ sessionCommandTestHooks = hooks ;
58+ }
59+
60+ function createDaemonCliClient ( ) {
61+ return sessionCommandTestHooks ?. createClient ?.( ) ?? new McpHunkDaemonCliClient ( ) ;
62+ }
63+
3964function extractToolValue < ResultType > (
4065 result : Awaited < ReturnType < Client [ "callTool" ] > > ,
4166 key : string ,
4267) : ResultType | undefined {
68+ const content = ( result . content ?? [ ] ) as Array < { type ?: string ; text ?: string } > ;
69+ const text = content . find ( ( entry ) => entry . type === "text" ) ?. text ;
70+
71+ if ( result . isError ) {
72+ throw new Error ( text || "The Hunk daemon returned an MCP tool error." ) ;
73+ }
74+
4375 const structured = result . structuredContent as Record < string , ResultType > | undefined ;
4476 if ( structured && key in structured ) {
4577 return structured [ key ] ;
4678 }
4779
48- const content = ( result . content ?? [ ] ) as Array < { type ?: string ; text ?: string } > ;
49- const text = content . find ( ( entry ) => entry . type === "text" ) ?. text ;
5080 if ( ! text ) {
5181 return undefined ;
5282 }
@@ -75,6 +105,11 @@ class McpHunkDaemonCliClient implements HunkDaemonCliClient {
75105 await this . transport . close ( ) . catch ( ( ) => undefined ) ;
76106 }
77107
108+ async listToolNames ( ) {
109+ const result = await this . client . listTools ( ) ;
110+ return new Set ( result . tools . map ( ( tool ) => tool . name ) ) ;
111+ }
112+
78113 async listSessions ( ) {
79114 const result = await this . client . callTool ( {
80115 name : "list_sessions" ,
@@ -202,6 +237,141 @@ class McpHunkDaemonCliClient implements HunkDaemonCliClient {
202237 }
203238}
204239
240+ async function readDaemonHealth ( ) {
241+ const config = resolveHunkMcpConfig ( ) ;
242+
243+ try {
244+ const response = await fetch ( `${ config . httpOrigin } /health` ) ;
245+ if ( ! response . ok ) {
246+ return null ;
247+ }
248+
249+ return ( await response . json ( ) ) as {
250+ ok : boolean ;
251+ pid ?: number ;
252+ sessions ?: number ;
253+ } ;
254+ } catch {
255+ return null ;
256+ }
257+ }
258+
259+ async function waitForDaemonShutdown ( timeoutMs = 3_000 ) {
260+ const config = resolveHunkMcpConfig ( ) ;
261+ const deadline = Date . now ( ) + timeoutMs ;
262+
263+ while ( Date . now ( ) < deadline ) {
264+ if ( ! ( await isHunkDaemonHealthy ( config ) ) ) {
265+ return true ;
266+ }
267+
268+ await Bun . sleep ( 100 ) ;
269+ }
270+
271+ return false ;
272+ }
273+
274+ function sessionMatchesSelector ( session : ListedSession , selector : SessionSelectorInput ) {
275+ if ( selector . sessionId ) {
276+ return session . sessionId === selector . sessionId ;
277+ }
278+
279+ if ( selector . repoRoot ) {
280+ return session . repoRoot === selector . repoRoot ;
281+ }
282+
283+ return true ;
284+ }
285+
286+ async function waitForSessionRegistration ( selector : SessionSelectorInput , timeoutMs = 8_000 ) {
287+ const deadline = Date . now ( ) + timeoutMs ;
288+
289+ while ( Date . now ( ) < deadline ) {
290+ const client = createDaemonCliClient ( ) ;
291+ await client . connect ( ) ;
292+
293+ try {
294+ const sessions = await client . listSessions ( ) ;
295+ if ( sessions . some ( ( session ) => sessionMatchesSelector ( session , selector ) ) ) {
296+ return true ;
297+ }
298+ } finally {
299+ await client . close ( ) ;
300+ }
301+
302+ await Bun . sleep ( 200 ) ;
303+ }
304+
305+ return false ;
306+ }
307+
308+ async function restartDaemonForMissingTools ( missingTools : string [ ] , selector ?: SessionSelectorInput ) {
309+ const health = await readDaemonHealth ( ) ;
310+ const pid = health ?. pid ;
311+ if ( ! pid || pid === process . pid ) {
312+ throw new Error (
313+ `The running Hunk MCP daemon is missing required tools (${ missingTools . join ( ", " ) } ). ` +
314+ `Restart Hunk so it can launch a fresh daemon from the current source tree.` ,
315+ ) ;
316+ }
317+
318+ process . kill ( pid , "SIGTERM" ) ;
319+
320+ const shutDown = await waitForDaemonShutdown ( ) ;
321+ if ( ! shutDown ) {
322+ throw new Error (
323+ `Stopped waiting for the old Hunk MCP daemon to exit after it was found missing required tools (${ missingTools . join ( ", " ) } ).` ,
324+ ) ;
325+ }
326+
327+ launchHunkDaemon ( ) ;
328+
329+ const config = resolveHunkMcpConfig ( ) ;
330+ const ready = await waitForHunkDaemonHealth ( { config, timeoutMs : 3_000 } ) ;
331+ if ( ! ready ) {
332+ throw new Error ( "Timed out waiting for the refreshed Hunk MCP daemon to start." ) ;
333+ }
334+
335+ if ( selector ) {
336+ const registered = await waitForSessionRegistration ( selector ) ;
337+ if ( ! registered ) {
338+ throw new Error (
339+ "Timed out waiting for the live Hunk session to reconnect after refreshing the MCP daemon." ,
340+ ) ;
341+ }
342+ }
343+ }
344+
345+ async function ensureRequiredTools ( action : SessionCommandInput [ "action" ] , selector ?: SessionSelectorInput ) {
346+ const requiredTools = REQUIRED_TOOLS_BY_ACTION [ action ] ?? [ ] ;
347+ if ( requiredTools . length === 0 ) {
348+ return ;
349+ }
350+
351+ const client = createDaemonCliClient ( ) ;
352+ await client . connect ( ) ;
353+
354+ try {
355+ const toolNames = await client . listToolNames ( ) ;
356+ const missingTools = requiredTools . filter ( ( tool ) => ! toolNames . has ( tool ) ) ;
357+ if ( missingTools . length === 0 ) {
358+ return ;
359+ }
360+
361+ const looksLikeOlderHunkDaemon = toolNames . has ( "list_sessions" ) && toolNames . has ( "get_session" ) ;
362+ if ( ! looksLikeOlderHunkDaemon ) {
363+ throw new Error (
364+ `The Hunk MCP daemon is missing required tools (${ missingTools . join ( ", " ) } ). Available tools: ${ [ ...toolNames ] . join ( ", " ) || "(none)" } .` ,
365+ ) ;
366+ }
367+ } finally {
368+ await client . close ( ) ;
369+ }
370+
371+ await ( sessionCommandTestHooks ?. restartDaemonForMissingTools ?.( requiredTools , selector )
372+ ?? restartDaemonForMissingTools ( requiredTools , selector ) ) ;
373+ }
374+
205375function stringifyJson ( value : unknown ) {
206376 return `${ JSON . stringify ( value , null , 2 ) } \n` ;
207377}
@@ -347,12 +517,15 @@ function renderOutput(output: SessionCommandOutput, value: unknown, formatText:
347517}
348518
349519export async function runSessionCommand ( input : SessionCommandInput ) {
350- const daemonAvailable = await resolveDaemonAvailability ( input . action ) ;
520+ const daemonAvailable = await ( sessionCommandTestHooks ?. resolveDaemonAvailability ?. ( input . action ) ?? resolveDaemonAvailability ( input . action ) ) ;
351521 if ( ! daemonAvailable && input . action === "list" ) {
352522 return renderOutput ( input . output , { sessions : [ ] } , ( ) => formatListOutput ( [ ] ) ) ;
353523 }
354524
355- const client = new McpHunkDaemonCliClient ( ) ;
525+ const normalizedSelector = "selector" in input ? normalizeRepoRoot ( input . selector ) : null ;
526+ await ensureRequiredTools ( input . action , normalizedSelector ?? undefined ) ;
527+
528+ const client = createDaemonCliClient ( ) ;
356529 await client . connect ( ) ;
357530
358531 try {
@@ -362,45 +535,45 @@ export async function runSessionCommand(input: SessionCommandInput) {
362535 return renderOutput ( input . output , { sessions } , ( ) => formatListOutput ( sessions ) ) ;
363536 }
364537 case "get" : {
365- const session = await client . getSession ( normalizeRepoRoot ( input . selector ) ) ;
538+ const session = await client . getSession ( normalizedSelector ! ) ;
366539 return renderOutput ( input . output , { session } , ( ) => formatSessionOutput ( session ) ) ;
367540 }
368541 case "context" : {
369- const context = await client . getSelectedContext ( normalizeRepoRoot ( input . selector ) ) ;
542+ const context = await client . getSelectedContext ( normalizedSelector ! ) ;
370543 return renderOutput ( input . output , { context } , ( ) => formatContextOutput ( context ) ) ;
371544 }
372545 case "navigate" : {
373546 const result = await client . navigateToHunk ( {
374547 ...input ,
375- selector : normalizeRepoRoot ( input . selector ) ,
548+ selector : normalizedSelector ! ,
376549 } ) ;
377550 return renderOutput ( input . output , { result } , ( ) => formatNavigationOutput ( input . selector , result ) ) ;
378551 }
379552 case "comment-add" : {
380553 const result = await client . addComment ( {
381554 ...input ,
382- selector : normalizeRepoRoot ( input . selector ) ,
555+ selector : normalizedSelector ! ,
383556 } ) ;
384557 return renderOutput ( input . output , { result } , ( ) => formatCommentOutput ( input . selector , result ) ) ;
385558 }
386559 case "comment-list" : {
387560 const comments = await client . listComments ( {
388561 ...input ,
389- selector : normalizeRepoRoot ( input . selector ) ,
562+ selector : normalizedSelector ! ,
390563 } ) ;
391564 return renderOutput ( input . output , { comments } , ( ) => formatCommentListOutput ( input . selector , comments ) ) ;
392565 }
393566 case "comment-rm" : {
394567 const result = await client . removeComment ( {
395568 ...input ,
396- selector : normalizeRepoRoot ( input . selector ) ,
569+ selector : normalizedSelector ! ,
397570 } ) ;
398571 return renderOutput ( input . output , { result } , ( ) => formatRemoveCommentOutput ( input . selector , result ) ) ;
399572 }
400573 case "comment-clear" : {
401574 const result = await client . clearComments ( {
402575 ...input ,
403- selector : normalizeRepoRoot ( input . selector ) ,
576+ selector : normalizedSelector ! ,
404577 } ) ;
405578 return renderOutput ( input . output , { result } , ( ) => formatClearCommentsOutput ( input . selector , result ) ) ;
406579 }
0 commit comments