11/**
22 * OpenCode provider — bridges Plannotator's AI layer with OpenCode's agent server.
33 *
4- * Uses @opencode-ai/sdk to spawn `opencode serve` and communicate via HTTP + SSE.
5- * One server per provider, shared across all sessions. The user must have the
6- * `opencode` CLI installed and authenticated.
4+ * Uses @opencode-ai/sdk to connect to an existing `opencode serve` first and
5+ * only spawns a new server when nothing is reachable. One server is shared
6+ * across all sessions. The user must have the `opencode` CLI installed and
7+ * authenticated.
78 */
89
10+ import type { OpencodeClient } from "@opencode-ai/sdk" ;
911import { BaseSession } from "../base-session.ts" ;
1012import { buildSystemPrompt } from "../context.ts" ;
1113import type {
@@ -54,17 +56,17 @@ export class OpenCodeProvider implements AIProvider {
5456 private config : OpenCodeConfig ;
5557 // biome-ignore lint/suspicious/noExplicitAny: SDK types not available at compile time
5658 private server : { url : string ; close : ( ) => void } | null = null ;
57- // biome-ignore lint/suspicious/noExplicitAny: SDK types not available at compile time
58- private client : any = null ;
59+ private client : OpencodeClient | null = null ;
5960 private startPromise : Promise < void > | null = null ;
61+ private lastAttachError : string | null = null ;
6062
6163 constructor ( config : OpenCodeConfig ) {
6264 this . config = config ;
6365 }
6466
65- /** Lazy-spawn the OpenCode server and create the HTTP client . */
67+ /** Attach to an existing OpenCode server or spawn one if needed . */
6668 async ensureServer ( ) : Promise < void > {
67- if ( this . server && this . client ) return ;
69+ if ( this . client ) return ;
6870 this . startPromise ??= this . doStart ( ) . catch ( ( err ) => {
6971 this . startPromise = null ;
7072 throw err ;
@@ -73,32 +75,80 @@ export class OpenCodeProvider implements AIProvider {
7375 }
7476
7577 private async doStart ( ) : Promise < void > {
78+ this . lastAttachError = null ;
7679 const { createOpencodeServer, createOpencodeClient } = await getSDK ( ) ;
80+ const attachedClient = await this . tryAttachExistingServer ( createOpencodeClient ) ;
81+ if ( attachedClient ) {
82+ this . client = attachedClient ;
83+ return ;
84+ }
7785
78- this . server = await createOpencodeServer ( {
79- hostname : this . config . hostname ?? "127.0.0.1" ,
80- ...( this . config . port != null && { port : this . config . port } ) ,
81- timeout : 15_000 ,
82- } ) ;
86+ try {
87+ this . server = await createOpencodeServer ( {
88+ hostname : this . config . hostname ?? "127.0.0.1" ,
89+ ...( this . config . port != null && { port : this . config . port } ) ,
90+ timeout : 15_000 ,
91+ } ) ;
92+ } catch ( err ) {
93+ const spawnMessage = err instanceof Error ? err . message : String ( err ) ;
94+ if ( this . lastAttachError ) {
95+ throw new Error ( `${ this . lastAttachError } \nFallback startup also failed: ${ spawnMessage } ` ) ;
96+ }
97+ throw err ;
98+ }
8399
84100 this . client = createOpencodeClient ( {
85101 baseUrl : this . server ! . url ,
86102 directory : this . config . cwd ?? process . cwd ( ) ,
87103 } ) ;
88104 }
89105
106+ private async tryAttachExistingServer (
107+ createOpencodeClient : ( config ?: { baseUrl ?: string ; directory ?: string } ) => OpencodeClient ,
108+ ) : Promise < OpencodeClient | null > {
109+ const cwd = this . config . cwd ?? process . cwd ( ) ;
110+ const baseUrl = `http://${ this . config . hostname ?? "127.0.0.1" } :${ this . config . port ?? 4096 } ` ;
111+ const client = createOpencodeClient ( {
112+ baseUrl,
113+ directory : cwd ,
114+ } ) ;
115+
116+ try {
117+ await client . config . get ( {
118+ throwOnError : true ,
119+ signal : AbortSignal . timeout ( 1_000 ) ,
120+ } ) ;
121+ return client ;
122+ } catch ( err ) {
123+ const message = err instanceof Error ? err . message : String ( err ) ;
124+ this . lastAttachError = `Failed to attach to existing OpenCode server at ${ baseUrl } : ${ message } ` ;
125+ return null ;
126+ }
127+ }
128+
129+ private getClient ( ) : OpencodeClient {
130+ if ( ! this . client ) {
131+ throw new Error ( "OpenCode client is not initialized." ) ;
132+ }
133+ return this . client ;
134+ }
135+
90136 async createSession ( options : CreateSessionOptions ) : Promise < AISession > {
91137 await this . ensureServer ( ) ;
138+ const client = this . getClient ( ) ;
92139
93- const result = await this . client . session . create ( {
140+ const result = await client . session . create ( {
94141 query : { directory : options . cwd ?? this . config . cwd ?? process . cwd ( ) } ,
95142 } ) ;
96143 const sessionData = result . data ;
144+ if ( ! sessionData ) {
145+ throw new Error ( "OpenCode did not return session data." ) ;
146+ }
97147
98148 const session = new OpenCodeSession ( {
99149 sessionId : sessionData . id ,
100150 systemPrompt : buildSystemPrompt ( options . context ) ,
101- client : this . client ,
151+ client,
102152 model : options . model ,
103153 parentSessionId : null ,
104154 } ) ;
@@ -107,36 +157,41 @@ export class OpenCodeProvider implements AIProvider {
107157
108158 async forkSession ( options : CreateSessionOptions ) : Promise < AISession > {
109159 await this . ensureServer ( ) ;
160+ const client = this . getClient ( ) ;
110161
111162 const parentId = options . context . parent ?. sessionId ;
112163 if ( ! parentId ) {
113164 throw new Error ( "Fork requires a parent session ID." ) ;
114165 }
115166
116- const result = await this . client . session . fork ( {
167+ const result = await client . session . fork ( {
117168 path : { id : parentId } ,
118169 } ) ;
119170 const sessionData = result . data ;
171+ if ( ! sessionData ) {
172+ throw new Error ( "OpenCode did not return forked session data." ) ;
173+ }
120174
121175 return new OpenCodeSession ( {
122176 sessionId : sessionData . id ,
123177 systemPrompt : buildSystemPrompt ( options . context ) ,
124- client : this . client ,
178+ client,
125179 model : options . model ,
126180 parentSessionId : parentId ,
127181 } ) ;
128182 }
129183
130184 async resumeSession ( sessionId : string ) : Promise < AISession > {
131185 await this . ensureServer ( ) ;
186+ const client = this . getClient ( ) ;
132187
133188 // Verify session exists
134- await this . client . session . get ( { path : { id : sessionId } } ) ;
189+ await client . session . get ( { path : { id : sessionId } } ) ;
135190
136191 return new OpenCodeSession ( {
137192 sessionId,
138193 systemPrompt : null ,
139- client : this . client ,
194+ client,
140195 model : undefined ,
141196 parentSessionId : null ,
142197 } ) ;
@@ -146,32 +201,33 @@ export class OpenCodeProvider implements AIProvider {
146201 if ( this . server ) {
147202 this . server . close ( ) ;
148203 this . server = null ;
149- this . client = null ;
150- this . startPromise = null ;
151204 }
205+ this . client = null ;
206+ this . startPromise = null ;
152207 }
153208
154209 /** Fetch available models from OpenCode. Call before registering the provider. */
155210 async fetchModels ( ) : Promise < void > {
156211 try {
157212 await this . ensureServer ( ) ;
213+ const client = this . getClient ( ) ;
158214
159- const result = await this . client . provider . list ( {
215+ const result = await client . provider . list ( {
160216 query : { directory : this . config . cwd ?? process . cwd ( ) } ,
161217 } ) ;
162218 const data = result . data ;
163- const connected = new Set ( data . connected as string [ ] ) ;
164- const allProviders = data . all as Array < {
165- id : string ;
166- models : Record < string , { id : string ; providerID : string ; name : string } > ;
167- } > ;
219+ if ( ! data ) {
220+ return ;
221+ }
222+ const connected = new Set ( data . connected ?? [ ] ) ;
223+ const allProviders = data . all ?? [ ] ;
168224
169225 const models : Array < { id : string ; label : string ; default ?: boolean } > = [ ] ;
170226 for ( const provider of allProviders ) {
171227 if ( ! connected . has ( provider . id ) ) continue ;
172228 for ( const model of Object . values ( provider . models ) ) {
173229 models . push ( {
174- id : `${ model . providerID } /${ model . id } ` ,
230+ id : `${ provider . id } /${ model . id } ` ,
175231 label : model . name ?? model . id ,
176232 } ) ;
177233 }
0 commit comments