@@ -20,12 +20,15 @@ import {
2020 AgentServiceEvent ,
2121 type AgentServiceEvents ,
2222 type Credentials ,
23+ type InterruptReason ,
2324 type PromptOutput ,
2425 type ReconnectSessionInput ,
2526 type SessionResponse ,
2627 type StartSessionInput ,
2728} from "./schemas.js" ;
2829
30+ export type { InterruptReason } ;
31+
2932const log = logger . scope ( "agent-service" ) ;
3033
3134function isAuthError ( error : unknown ) : boolean {
@@ -130,6 +133,8 @@ interface SessionConfig {
130133 sdkSessionId ?: string ;
131134 model ?: string ;
132135 executionMode ?: "plan" | "acceptEdits" | "default" ;
136+ /** Additional directories Claude can access beyond cwd (for worktree support) */
137+ additionalDirectories ?: string [ ] ;
133138}
134139
135140interface ManagedSession {
@@ -143,7 +148,10 @@ interface ManagedSession {
143148 lastActivityAt : number ;
144149 mockNodeDir : string ;
145150 config : SessionConfig ;
151+ interruptReason ?: InterruptReason ;
146152 needsRecreation : boolean ;
153+ promptPending : boolean ;
154+ pendingCwdContext ?: string ;
147155}
148156
149157function getClaudeCliPath ( ) : string {
@@ -318,6 +326,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
318326 sdkSessionId,
319327 model,
320328 executionMode,
329+ additionalDirectories,
321330 } = config ;
322331
323332 if ( ! isRetry ) {
@@ -369,6 +378,11 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
369378 persistence : { taskId, runId : taskRunId , logUrl } ,
370379 } ) ,
371380 ...( sdkSessionId && { sdkSessionId } ) ,
381+ ...( additionalDirectories ?. length && {
382+ claudeCode : {
383+ options : { additionalDirectories } ,
384+ } ,
385+ } ) ,
372386 } ,
373387 } ) ;
374388 } else {
@@ -379,6 +393,11 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
379393 sessionId : taskRunId ,
380394 model,
381395 ...( executionMode && { initialModeId : executionMode } ) ,
396+ ...( additionalDirectories ?. length && {
397+ claudeCode : {
398+ options : { additionalDirectories } ,
399+ } ,
400+ } ) ,
382401 } ,
383402 } ) ;
384403 }
@@ -395,6 +414,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
395414 mockNodeDir,
396415 config,
397416 needsRecreation : false ,
417+ promptPending : false ,
398418 } ;
399419
400420 this . sessions . set ( taskRunId , session ) ;
@@ -428,14 +448,22 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
428448
429449 log . info ( "Recreating session" , { taskRunId } ) ;
430450
451+ // Preserve state that should survive recreation
431452 const config = existing . config ;
453+ const pendingCwdContext = existing . pendingCwdContext ;
454+
432455 this . cleanupSession ( taskRunId ) ;
433456
434457 const newSession = await this . getOrCreateSession ( config , true ) ;
435458 if ( ! newSession ) {
436459 throw new Error ( `Failed to recreate session: ${ taskRunId } ` ) ;
437460 }
438461
462+ // Restore preserved state
463+ if ( pendingCwdContext ) {
464+ newSession . pendingCwdContext = pendingCwdContext ;
465+ }
466+
439467 return newSession ;
440468 }
441469
@@ -456,25 +484,45 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
456484 session = await this . recreateSession ( sessionId ) ;
457485 }
458486
487+ // Prepend pending CWD context if present
488+ let finalPrompt = prompt ;
489+ if ( session . pendingCwdContext ) {
490+ log . info ( "Prepending CWD context to prompt" , { sessionId } ) ;
491+ finalPrompt = [
492+ { type : "text" , text : `_${ session . pendingCwdContext } _\n\n` } ,
493+ ...prompt ,
494+ ] ;
495+ session . pendingCwdContext = undefined ;
496+ }
497+
459498 session . lastActivityAt = Date . now ( ) ;
499+ session . promptPending = true ;
460500
461501 try {
462502 const result = await session . connection . prompt ( {
463503 sessionId,
464- prompt,
504+ prompt : finalPrompt ,
465505 } ) ;
466- return { stopReason : result . stopReason } ;
506+ return {
507+ stopReason : result . stopReason ,
508+ _meta : result . _meta as PromptOutput [ "_meta" ] ,
509+ } ;
467510 } catch ( err ) {
468511 if ( isAuthError ( err ) ) {
469512 log . warn ( "Auth error during prompt, recreating session" , { sessionId } ) ;
470513 session = await this . recreateSession ( sessionId ) ;
471514 const result = await session . connection . prompt ( {
472515 sessionId,
473- prompt,
516+ prompt : finalPrompt ,
474517 } ) ;
475- return { stopReason : result . stopReason } ;
518+ return {
519+ stopReason : result . stopReason ,
520+ _meta : result . _meta as PromptOutput [ "_meta" ] ,
521+ } ;
476522 }
477523 throw err ;
524+ } finally {
525+ session . promptPending = false ;
478526 }
479527 }
480528
@@ -492,12 +540,22 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
492540 }
493541 }
494542
495- async cancelPrompt ( sessionId : string ) : Promise < boolean > {
543+ async cancelPrompt (
544+ sessionId : string ,
545+ reason ?: InterruptReason ,
546+ ) : Promise < boolean > {
496547 const session = this . sessions . get ( sessionId ) ;
497548 if ( ! session ) return false ;
498549
499550 try {
500- await session . connection . cancel ( { sessionId } ) ;
551+ await session . connection . cancel ( {
552+ sessionId,
553+ _meta : reason ? { interruptReason : reason } : undefined ,
554+ } ) ;
555+ if ( reason ) {
556+ session . interruptReason = reason ;
557+ log . info ( "Session interrupted" , { sessionId, reason } ) ;
558+ }
501559 return true ;
502560 } catch ( err ) {
503561 log . error ( "Failed to cancel prompt" , { sessionId, err } ) ;
@@ -550,6 +608,92 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
550608 return taskId ? all . filter ( ( s ) => s . taskId === taskId ) : all ;
551609 }
552610
611+ /**
612+ * Get sessions that were interrupted for a specific reason.
613+ * Optionally filter by repoPath to get only sessions for a specific repo.
614+ */
615+ getInterruptedSessions (
616+ reason : InterruptReason ,
617+ repoPath ?: string ,
618+ ) : ManagedSession [ ] {
619+ return Array . from ( this . sessions . values ( ) ) . filter (
620+ ( s ) =>
621+ s . interruptReason === reason &&
622+ ( repoPath === undefined || s . repoPath === repoPath ) ,
623+ ) ;
624+ }
625+
626+ /**
627+ * Resume an interrupted session by clearing the interrupt reason
628+ * and sending a continue prompt.
629+ */
630+ async resumeInterruptedSession ( sessionId : string ) : Promise < PromptOutput > {
631+ const session = this . sessions . get ( sessionId ) ;
632+ if ( ! session ) {
633+ throw new Error ( `Session not found: ${ sessionId } ` ) ;
634+ }
635+
636+ if ( ! session . interruptReason ) {
637+ throw new Error ( `Session ${ sessionId } was not interrupted` ) ;
638+ }
639+
640+ log . info ( "Resuming interrupted session" , {
641+ sessionId,
642+ reason : session . interruptReason ,
643+ } ) ;
644+
645+ // Clear the interrupt reason
646+ session . interruptReason = undefined ;
647+
648+ // Send a continue prompt
649+ return this . prompt ( sessionId , [
650+ { type : "text" , text : "Continue where you left off." } ,
651+ ] ) ;
652+ }
653+
654+ /**
655+ * Notify a session that its working directory context has changed.
656+ * Used when focusing/unfocusing worktrees - the agent doesn't need to respawn
657+ * because it has additionalDirectories configured, but it should know about the change.
658+ */
659+ async notifyCwdChange (
660+ sessionId : string ,
661+ newPath : string ,
662+ reason : "moving_to_worktree" | "moving_to_local" ,
663+ ) : Promise < void > {
664+ const session = this . sessions . get ( sessionId ) ;
665+ if ( ! session ) {
666+ log . warn ( "Session not found for CWD notification" , { sessionId } ) ;
667+ return ;
668+ }
669+
670+ const contextMessage =
671+ reason === "moving_to_worktree"
672+ ? `Your working directory has been moved to a worktree at ${ newPath } because the user focused on another task.`
673+ : `Your working directory has been returned to the main repository at ${ newPath } .` ;
674+
675+ // Check if session is currently busy
676+ if ( session . promptPending ) {
677+ // Active session: send immediately with continue instruction
678+ this . prompt ( sessionId , [
679+ {
680+ type : "text" ,
681+ text : `${ contextMessage } Continue where you left off.` ,
682+ } ,
683+ ] ) ;
684+ } else {
685+ // Idle session: store for prepending to next user message
686+ session . pendingCwdContext = contextMessage ;
687+ }
688+
689+ log . info ( "Notified session of CWD change" , {
690+ sessionId,
691+ newPath,
692+ reason,
693+ wasPromptPending : session . promptPending ,
694+ } ) ;
695+ }
696+
553697 async cleanupAll ( ) : Promise < void > {
554698 log . info ( "Cleaning up all agent sessions" , {
555699 sessionCount : this . sessions . size ,
@@ -813,6 +957,10 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
813957 model : "model" in params ? params . model : undefined ,
814958 executionMode :
815959 "executionMode" in params ? params . executionMode : undefined ,
960+ additionalDirectories :
961+ "additionalDirectories" in params
962+ ? params . additionalDirectories
963+ : undefined ,
816964 } ;
817965 }
818966
0 commit comments