22 * @fileoverview Gemini CLI adapter integrating with the HeadlessCoder contract.
33 */
44
5- import { spawn , ChildProcess } from 'node:child_process' ;
5+ import { spawn , spawnSync , ChildProcess } from 'node:child_process' ;
66import * as readline from 'node:readline' ;
77import { once } from 'node:events' ;
88import {
@@ -56,6 +56,7 @@ const DONE = Symbol('gemini-stream-done');
5656
5757interface GeminiThreadState {
5858 id ?: string ;
59+ resumeToken ?: string ;
5960 opts : StartOpts ;
6061 currentRun ?: ActiveRun | null ;
6162}
@@ -121,6 +122,109 @@ function extractJsonPayload(text: string | undefined): unknown | undefined {
121122 }
122123}
123124
125+ function captureGeminiSessionMetadata ( state : GeminiThreadState , handle : ThreadHandle | undefined , payload : any ) : void {
126+ const sessionId = extractSessionId ( payload ) ;
127+ if ( sessionId ) {
128+ state . id = sessionId ;
129+ state . resumeToken = sessionId ;
130+ if ( handle ) {
131+ handle . id = sessionId ;
132+ }
133+ return ;
134+ }
135+ if ( state . id ) return ;
136+ const resumeIndex = extractSessionIndex ( payload ) ;
137+ if ( resumeIndex ) {
138+ state . resumeToken = resumeIndex ;
139+ }
140+ }
141+
142+ function extractSessionId ( payload : any ) : string | undefined {
143+ const candidate =
144+ payload ?. session_id ??
145+ payload ?. sessionId ??
146+ payload ?. session ?. id ??
147+ payload ?. session ?. session_id ??
148+ payload ?. metadata ?. session_id ;
149+ return typeof candidate === 'string' && candidate . length > 0 ? candidate : undefined ;
150+ }
151+
152+ function extractSessionIndex ( payload : any ) : string | undefined {
153+ const candidate =
154+ payload ?. session_index ??
155+ payload ?. sessionIndex ??
156+ payload ?. index ??
157+ payload ?. session ?. index ??
158+ payload ?. session ?. session_index ??
159+ payload ?. metadata ?. session_index ;
160+ if ( typeof candidate === 'number' && Number . isFinite ( candidate ) ) {
161+ return String ( candidate ) ;
162+ }
163+ if ( typeof candidate === 'string' && candidate . trim ( ) ) {
164+ return candidate . trim ( ) ;
165+ }
166+ return undefined ;
167+ }
168+
169+ function resolveResumeTarget ( state : GeminiThreadState ) : string | undefined {
170+ if ( state . resumeToken ) return state . resumeToken ;
171+ if ( state . id ) return state . id ;
172+ const candidate = state . opts ?. resume ;
173+ return typeof candidate === 'string' && candidate . length > 0 ? candidate : undefined ;
174+ }
175+
176+ interface GeminiSessionEntry {
177+ index : number ;
178+ id ?: string ;
179+ }
180+
181+ function updateSessionMetadataFromList ( state : GeminiThreadState , handle : ThreadHandle | undefined ) : void {
182+ const entries = listGeminiSessions ( state . opts ?? { } ) ;
183+ if ( ! entries . length ) return ;
184+ const latest = entries [ entries . length - 1 ] ;
185+ if ( latest . id ) {
186+ state . id = latest . id ;
187+ state . resumeToken = latest . id ;
188+ if ( handle ) {
189+ handle . id = latest . id ;
190+ }
191+ return ;
192+ }
193+ if ( ! state . id && Number . isFinite ( latest . index ) ) {
194+ state . resumeToken = String ( latest . index ) ;
195+ }
196+ }
197+
198+ function listGeminiSessions ( opts : StartOpts ) : GeminiSessionEntry [ ] {
199+ const args = [ '--list-sessions' ] ;
200+ const result = spawnSync ( geminiPath ( opts . geminiBinaryPath ) , args , {
201+ cwd : opts . workingDirectory ?? process . cwd ( ) ,
202+ env : process . env ,
203+ encoding : 'utf8' ,
204+ stdio : [ 'ignore' , 'pipe' , 'pipe' ] ,
205+ } ) ;
206+ if ( result . error || typeof result . status === 'number' && result . status !== 0 ) {
207+ return [ ] ;
208+ }
209+ const output = ( result . stdout && result . stdout . trim ( ) . length ? result . stdout : result . stderr ) ?? '' ;
210+ const parsed = parseGeminiSessionList ( output ) ;
211+ return parsed ;
212+ }
213+
214+ function parseGeminiSessionList ( output : string ) : GeminiSessionEntry [ ] {
215+ const entries : GeminiSessionEntry [ ] = [ ] ;
216+ const lineRegex = / ^ \s * ( \d + ) \. \s .* \[ ( .+ ?) \] \s * $ / ;
217+ for ( const line of output . split ( / \r ? \n / ) ) {
218+ const match = lineRegex . exec ( line ) ;
219+ if ( ! match ) continue ;
220+ const index = Number . parseInt ( match [ 1 ] , 10 ) ;
221+ if ( Number . isNaN ( index ) ) continue ;
222+ const id = match [ 2 ] ?. trim ( ) ;
223+ entries . push ( { index, id : id || undefined } ) ;
224+ }
225+ return entries ;
226+ }
227+
124228/**
125229 * Adapter that proxies Gemini CLI headless invocations.
126230 *
@@ -147,7 +251,11 @@ export class GeminiAdapter implements HeadlessCoder {
147251 */
148252 async startThread ( opts ?: StartOpts ) : Promise < ThreadHandle > {
149253 const options = { ...this . defaultOpts , ...opts } ;
150- const state : GeminiThreadState = { opts : options } ;
254+ const state : GeminiThreadState = {
255+ opts : options ,
256+ id : typeof options . resume === 'string' ? options . resume : undefined ,
257+ resumeToken : typeof options . resume === 'string' ? options . resume : undefined ,
258+ } ;
151259 return this . createThreadHandle ( state ) ;
152260 }
153261
@@ -162,8 +270,8 @@ export class GeminiAdapter implements HeadlessCoder {
162270 * Thread handle referencing Gemini state.
163271 */
164272 async resumeThread ( threadId : string , opts ?: StartOpts ) : Promise < ThreadHandle > {
165- const options = { ...this . defaultOpts , ...opts } ;
166- const state : GeminiThreadState = { opts : options , id : threadId } ;
273+ const options = { ...this . defaultOpts , ...opts , resume : threadId } ;
274+ const state : GeminiThreadState = { opts : options , id : threadId , resumeToken : threadId } ;
167275 return this . createThreadHandle ( state ) ;
168276 }
169277
@@ -196,9 +304,9 @@ export class GeminiAdapter implements HeadlessCoder {
196304 throw new Error ( `gemini exited with code ${ exitCode } : ${ stderr } ` ) ;
197305 }
198306 const parsed = parseGeminiJson ( stdout ) ;
199- if ( parsed . session_id ) {
200- state . id = parsed . session_id ;
201- handle . id = parsed . session_id ;
307+ captureGeminiSessionMetadata ( state , handle , parsed ) ;
308+ if ( ! state . id || ! state . resumeToken ) {
309+ updateSessionMetadataFromList ( state , handle ) ;
202310 }
203311 const text = parsed . response ?? parsed . text ?? stdout ;
204312 const structured = opts ?. outputSchema ? extractJsonPayload ( text ) : undefined ;
@@ -261,10 +369,7 @@ export class GeminiAdapter implements HeadlessCoder {
261369 } catch {
262370 return ;
263371 }
264- if ( event . session_id ) {
265- state . id = event . session_id ;
266- handle . id = event . session_id ;
267- }
372+ captureGeminiSessionMetadata ( state , handle , event ) ;
268373 for ( const normalized of normalizeGeminiEvent ( event ) ) {
269374 push ( normalized ) ;
270375 }
@@ -302,6 +407,8 @@ export class GeminiAdapter implements HeadlessCoder {
302407 }
303408 if ( code !== 0 ) {
304409 push ( new Error ( `gemini exited with code ${ code } ` ) ) ;
410+ } else if ( ! state . id || ! state . resumeToken ) {
411+ updateSessionMetadataFromList ( state , handle ) ;
305412 }
306413 push ( DONE ) ;
307414 } ;
@@ -370,7 +477,8 @@ export class GeminiAdapter implements HeadlessCoder {
370477 opts ?: RunOpts ,
371478 ) {
372479 const startOpts = state . opts ?? { } ;
373- const args = buildGeminiArgs ( startOpts , prompt , mode ) ;
480+ const resumeTarget = resolveResumeTarget ( state ) ;
481+ const args = buildGeminiArgs ( startOpts , prompt , mode , resumeTarget ) ;
374482 const child = spawn ( geminiPath ( startOpts . geminiBinaryPath ) , args , {
375483 cwd : startOpts . workingDirectory ,
376484 env : { ...process . env , ...( opts ?. extraEnv ?? { } ) } ,
@@ -556,13 +664,21 @@ function normalizeGeminiEvent(event: any): CoderStreamEvent[] {
556664 }
557665}
558666
559- function buildGeminiArgs ( opts : StartOpts , prompt : string , format : 'json' | 'stream-json' ) : string [ ] {
667+ function buildGeminiArgs (
668+ opts : StartOpts ,
669+ prompt : string ,
670+ format : 'json' | 'stream-json' ,
671+ resumeTarget ?: string ,
672+ ) : string [ ] {
560673 const args = [ '--output-format' , format , '--prompt' , prompt ] ;
561674 if ( opts . model ) args . push ( '--model' , opts . model ) ;
562675 if ( opts . includeDirectories ?. length ) {
563676 args . push ( '--include-directories' , opts . includeDirectories . join ( ',' ) ) ;
564677 }
565678 if ( opts . yolo ) args . push ( '--yolo' ) ;
679+ if ( resumeTarget ) {
680+ args . push ( '--resume' , resumeTarget ) ;
681+ }
566682 return args ;
567683}
568684
0 commit comments