@@ -38,8 +38,25 @@ export interface SidecarClientOptions {
3838 env ?: Record < string , string > ;
3939 /** Mirror child stderr to this process (debug aid). */
4040 debug ?: boolean ;
41+ /**
42+ * Recycle the child after this much idle time (default
43+ * {@link DEFAULT_IDLE_RECYCLE_MS}). See the field docs on `stale` for why.
44+ */
45+ idleRecycleMs ?: number ;
4146}
4247
48+ /**
49+ * Idle lifetime after which the child is recycled. The SDK inside the child
50+ * memoizes its streaming transport (and auth token) at module scope with no
51+ * reconnect logic, so a backend session dropped while idle leaves every later
52+ * run ending `status:"error"` until the process restarts. 10 minutes sits
53+ * comfortably below the shortest reported failure onset (15-30min); the
54+ * respawn cost is a cheap Node spawn paid only after an idle gap, and pooled
55+ * agents resume from Cursor's checkpoint store exactly as they do across an
56+ * opencode restart.
57+ */
58+ const DEFAULT_IDLE_RECYCLE_MS = 10 * 60 * 1000 ;
59+
4360interface Pending {
4461 resolve : ( msg : Record < string , unknown > ) => void ;
4562 reject : ( err : Error ) => void ;
@@ -63,6 +80,14 @@ export class SidecarClient {
6380 private readonly pending = new Map < number , Pending > ( ) ;
6481 private nextId = 1 ;
6582 private disposed = false ;
83+ /**
84+ * Set when a run ends terminally bad (status:"error" or a stream error).
85+ * The SDK's memoized transport does not recover from a dead backend
86+ * session, so the child is recycled before the next request instead of
87+ * failing every turn until the whole process is restarted.
88+ */
89+ private stale = false ;
90+ private idleTimer : ReturnType < typeof setTimeout > | undefined ;
6691
6792 constructor ( options : SidecarClientOptions ) {
6893 this . options = options ;
@@ -71,7 +96,9 @@ export class SidecarClient {
7196 /** Spawn (or reuse) the child process. */
7297 private ensureChild ( ) : ChildProcessByStdio < Writable , Readable , Readable > {
7398 if ( this . disposed ) throw new Error ( "cursor sidecar client disposed" ) ;
99+ if ( this . child && this . stale && this . pending . size === 0 ) this . recycleChild ( ) ;
74100 if ( this . child ) return this . child ;
101+ this . stale = false ;
75102
76103 const child = spawn ( this . options . nodePath ?? "node" , [ this . options . scriptPath ] , {
77104 stdio : [ "pipe" , "pipe" , "pipe" ] ,
@@ -87,14 +114,20 @@ export class SidecarClient {
87114 }
88115 } ) ;
89116 child . on ( "exit" , ( code ) => {
90- this . failAll ( new Error ( `cursor sidecar exited (code ${ code ?? "unknown" } )` ) ) ;
117+ // A recycled child's exit arrives after its replacement spawned; it
118+ // must not clobber the new child or reject its in-flight requests.
119+ if ( this . child !== child ) return ;
120+ // Clear before failAll so updateRefs doesn't arm the idle timer (or
121+ // re-unref pipes) against a child that's already gone.
91122 this . child = undefined ;
92123 this . reader ?. close ( ) ;
93124 this . reader = undefined ;
125+ this . failAll ( new Error ( `cursor sidecar exited (code ${ code ?? "unknown" } )` ) ) ;
94126 } ) ;
95127 child . on ( "error" , ( err ) => {
96- this . failAll ( new Error ( `cursor sidecar failed to start: ${ err . message } ` ) ) ;
128+ if ( this . child !== child ) return ;
97129 this . child = undefined ;
130+ this . failAll ( new Error ( `cursor sidecar failed to start: ${ err . message } ` ) ) ;
98131 } ) ;
99132 this . updateRefs ( ) ;
100133 return child ;
@@ -117,9 +150,44 @@ export class SidecarClient {
117150 for ( const target of refable ) target . ref ?.( ) ;
118151 } else {
119152 for ( const target of refable ) target . unref ?.( ) ;
153+ this . armIdleTimer ( ) ;
120154 }
121155 }
122156
157+ /**
158+ * Kill the child so the next request spawns a fresh one (fresh SDK module
159+ * state). Only called with nothing in flight; pooled agents are resumable,
160+ * so nothing is lost. The exit handler's failAll no-ops on an empty pending
161+ * map.
162+ */
163+ private recycleChild ( ) : void {
164+ this . clearIdleTimer ( ) ;
165+ this . stale = false ;
166+ const child = this . child ;
167+ this . child = undefined ;
168+ this . reader ?. close ( ) ;
169+ this . reader = undefined ;
170+ child ?. kill ( ) ;
171+ }
172+
173+ /**
174+ * Arm the idle-recycle timer. Unref'd like the child pipes so it can never
175+ * hold the parent's event loop open (see updateRefs).
176+ */
177+ private armIdleTimer ( ) : void {
178+ this . clearIdleTimer ( ) ;
179+ if ( this . disposed ) return ;
180+ this . idleTimer = setTimeout ( ( ) => {
181+ if ( this . pending . size === 0 ) this . recycleChild ( ) ;
182+ } , this . options . idleRecycleMs ?? DEFAULT_IDLE_RECYCLE_MS ) ;
183+ this . idleTimer . unref ?.( ) ;
184+ }
185+
186+ private clearIdleTimer ( ) : void {
187+ if ( this . idleTimer ) clearTimeout ( this . idleTimer ) ;
188+ this . idleTimer = undefined ;
189+ }
190+
123191 private failAll ( err : Error ) : void {
124192 for ( const pending of this . pending . values ( ) ) {
125193 pending . onStreamError ?.( err ) ;
@@ -150,12 +218,17 @@ export class SidecarClient {
150218 if ( ev === "result" ) {
151219 this . pending . delete ( id ) ;
152220 this . updateRefs ( ) ;
153- pending . onResult ?.( msg [ "result" ] as { status : string ; result ?: string } ) ;
221+ const result = msg [ "result" ] as { status : string ; result ?: string } ;
222+ // A terminally errored run marks the child stale: the SDK's memoized
223+ // transport can't be trusted after this, so recycle before next use.
224+ if ( result . status === "error" ) this . stale = true ;
225+ pending . onResult ?.( result ) ;
154226 return ;
155227 }
156228 if ( ev === "error" ) {
157229 this . pending . delete ( id ) ;
158230 this . updateRefs ( ) ;
231+ this . stale = true ;
159232 pending . onStreamError ?.( reviveError ( msg [ "error" ] ) ) ;
160233 return ;
161234 }
@@ -178,6 +251,7 @@ export class SidecarClient {
178251 payload : Record < string , unknown > ,
179252 hooks ?: Pick < Pending , "onUpdate" | "onResult" | "onStreamError" > ,
180253 ) : Promise < Record < string , unknown > > {
254+ this . clearIdleTimer ( ) ;
181255 const child = this . ensureChild ( ) ;
182256 const id = this . nextId ++ ;
183257 return new Promise < Record < string , unknown > > ( ( resolve , reject ) => {
@@ -262,6 +336,7 @@ export class SidecarClient {
262336 /** Kill the child and reject anything in flight. */
263337 dispose ( ) : void {
264338 this . disposed = true ;
339+ this . clearIdleTimer ( ) ;
265340 this . failAll ( new Error ( "cursor sidecar client disposed" ) ) ;
266341 this . reader ?. close ( ) ;
267342 this . reader = undefined ;
0 commit comments