@@ -6,9 +6,69 @@ import {
66 type SDKUserMessage ,
77 type Options ,
88 type CanUseTool ,
9- type PermissionResult ,
10- } from "@anthropic-ai/claude-agent-sdk" ;
11- import { logger } from "../logger.js" ;
9+ type PermissionResult
10+ } from '@anthropic-ai/claude-agent-sdk' ;
11+ import { existsSync } from 'node:fs' ;
12+ import { join } from 'node:path' ;
13+ import { logger } from '../logger.js' ;
14+
15+ // ---------------------------------------------------------------------------
16+ // Resolve Claude Code executable for packaged Electron
17+ // ---------------------------------------------------------------------------
18+
19+ /**
20+ * In a packaged Electron app the SDK's bundled cli.js lives inside the asar
21+ * archive. child_process.spawn() can't read from asar, so the spawned node
22+ * process fails with exit code 1.
23+ *
24+ * Resolution order:
25+ * 1. asar-unpacked cli.js (electron-builder asarUnpack extracts it)
26+ * 2. Globally installed `claude` native binary (searched via PATH)
27+ * 3. undefined → let the SDK use its default (works in CLI / dev mode)
28+ */
29+ function resolveClaudeCodePath ( ) : string | undefined {
30+ const isPackagedElectron =
31+ ! ! process . versions ?. electron && ! ( process as unknown as { defaultApp ?: boolean } ) . defaultApp ;
32+ if ( ! isPackagedElectron ) return undefined ;
33+
34+ // 1. Try asar-unpacked cli.js
35+ const resourcesPath = ( process as unknown as { resourcesPath ?: string } ) . resourcesPath ;
36+ if ( resourcesPath ) {
37+ const unpackedCli = join (
38+ resourcesPath ,
39+ 'app.asar.unpacked' ,
40+ 'node_modules' ,
41+ '@anthropic-ai' ,
42+ 'claude-agent-sdk' ,
43+ 'cli.js'
44+ ) ;
45+ if ( existsSync ( unpackedCli ) ) {
46+ logger . info ( 'Using asar-unpacked cli.js for Claude SDK' , {
47+ path : unpackedCli
48+ } ) ;
49+ return unpackedCli ;
50+ }
51+ }
52+
53+ // 2. Try globally installed claude binary from PATH
54+ const pathDirs = ( process . env . PATH || '' ) . split ( ':' ) ;
55+ for ( const dir of pathDirs ) {
56+ const candidate = join ( dir , 'claude' ) ;
57+ try {
58+ if ( existsSync ( candidate ) ) {
59+ logger . info ( 'Using global claude binary for SDK' , {
60+ path : candidate
61+ } ) ;
62+ return candidate ;
63+ }
64+ } catch {
65+ // permission error on dir — skip
66+ }
67+ }
68+
69+ logger . warn ( 'Packaged Electron: could not resolve Claude Code executable outside asar' ) ;
70+ return undefined ;
71+ }
1272
1373// ---------------------------------------------------------------------------
1474// Public types
@@ -19,15 +79,12 @@ export interface QueryOptions {
1979 cwd : string ;
2080 resume ?: string ;
2181 model ?: string ;
22- permissionMode ?: " default" | " acceptEdits" | " plan" | " bypassPermissions" ;
82+ permissionMode ?: ' default' | ' acceptEdits' | ' plan' | ' bypassPermissions' ;
2383 images ?: Array < {
24- type : " image" ;
25- source : { type : " base64" ; media_type : string ; data : string } ;
84+ type : ' image' ;
85+ source : { type : ' base64' ; media_type : string ; data : string } ;
2686 } > ;
27- onPermissionRequest ?: (
28- toolName : string ,
29- toolInput : string ,
30- ) => Promise < boolean > ;
87+ onPermissionRequest ?: ( toolName : string , toolInput : string ) => Promise < boolean > ;
3188}
3289
3390export interface QueryResult {
@@ -45,18 +102,18 @@ export interface QueryResult {
45102 */
46103function extractText ( msg : SDKAssistantMessage ) : string {
47104 const content = msg . message ?. content ;
48- if ( ! Array . isArray ( content ) ) return "" ;
105+ if ( ! Array . isArray ( content ) ) return '' ;
49106 return content
50- . filter ( ( block : any ) => block . type === " text" )
51- . map ( ( block : any ) => ( block . text as string ) ?? "" )
52- . join ( "" ) ;
107+ . filter ( ( block : any ) => block . type === ' text' )
108+ . map ( ( block : any ) => ( block . text as string ) ?? '' )
109+ . join ( '' ) ;
53110}
54111
55112/**
56113 * Extract session_id from any SDKMessage that carries one.
57114 */
58115function getSessionId ( msg : SDKMessage ) : string | undefined {
59- if ( " session_id" in msg ) {
116+ if ( ' session_id' in msg ) {
60117 return ( msg as { session_id : string } ) . session_id ;
61118 }
62119 return undefined ;
@@ -69,28 +126,28 @@ function getSessionId(msg: SDKMessage): string | undefined {
69126 */
70127async function * singleUserMessage (
71128 text : string ,
72- images ?: QueryOptions [ " images" ] ,
129+ images ?: QueryOptions [ ' images' ]
73130) : AsyncGenerator < SDKUserMessage , void , unknown > {
74131 const contentBlocks : Array < {
75132 type : string ;
76133 text ?: string ;
77- source ?: { type : " base64" ; media_type : string ; data : string } ;
78- } > = [ { type : " text" , text } ] ;
134+ source ?: { type : ' base64' ; media_type : string ; data : string } ;
135+ } > = [ { type : ' text' , text } ] ;
79136
80137 if ( images ?. length ) {
81138 for ( const img of images ) {
82- contentBlocks . push ( { type : " image" , source : img . source } ) ;
139+ contentBlocks . push ( { type : ' image' , source : img . source } ) ;
83140 }
84141 }
85142
86143 const msg : SDKUserMessage = {
87- type : " user" ,
88- session_id : "" ,
144+ type : ' user' ,
145+ session_id : '' ,
89146 parent_tool_use_id : null ,
90147 message : {
91- role : " user" ,
92- content : contentBlocks ,
93- } ,
148+ role : ' user' ,
149+ content : contentBlocks
150+ }
94151 } ;
95152
96153 yield msg ;
@@ -101,22 +158,14 @@ async function* singleUserMessage(
101158// ---------------------------------------------------------------------------
102159
103160export async function claudeQuery ( options : QueryOptions ) : Promise < QueryResult > {
104- const {
105- prompt,
106- cwd,
107- resume,
108- model,
109- permissionMode,
110- images,
111- onPermissionRequest,
112- } = options ;
161+ const { prompt, cwd, resume, model, permissionMode, images, onPermissionRequest } = options ;
113162
114- logger . info ( " Starting Claude query" , {
163+ logger . info ( ' Starting Claude query' , {
115164 cwd,
116165 model,
117166 permissionMode,
118167 resume : ! ! resume ,
119- hasImages : ! ! images ?. length ,
168+ hasImages : ! ! images ?. length
120169 } ) ;
121170
122171 // When images are present we use the multi-content AsyncIterable path;
@@ -130,45 +179,53 @@ export async function claudeQuery(options: QueryOptions): Promise<QueryResult> {
130179 const sdkOptions : Options = {
131180 cwd,
132181 permissionMode,
133- allowDangerouslySkipPermissions : permissionMode === "bypassPermissions" ,
134- settingSources : [ "user" , "project" ] ,
182+ allowDangerouslySkipPermissions : permissionMode === 'bypassPermissions' ,
183+ settingSources : [ 'user' , 'project' ] ,
184+ stderr : ( msg : string ) => logger . debug ( 'Claude SDK stderr' , { msg } )
135185 } ;
136186
187+ // In packaged Electron the SDK's bundled cli.js is inside asar and
188+ // can't be spawned by a child node process. Resolve an alternative.
189+ const claudeCodePath = resolveClaudeCodePath ( ) ;
190+ if ( claudeCodePath ) {
191+ sdkOptions . pathToClaudeCodeExecutable = claudeCodePath ;
192+ }
193+
137194 if ( model ) sdkOptions . model = model ;
138195 if ( resume ) sdkOptions . resume = resume ;
139196
140197 // Permission callback — bridges the SDK's CanUseTool to our simpler handler.
141198 if ( onPermissionRequest ) {
142199 const canUseTool : CanUseTool = async (
143200 toolName : string ,
144- input : Record < string , unknown > ,
201+ input : Record < string , unknown >
145202 ) : Promise < PermissionResult > => {
146203 const inputStr = JSON . stringify ( input ) ;
147- logger . info ( " Permission request from SDK" , { toolName } ) ;
204+ logger . info ( ' Permission request from SDK' , { toolName } ) ;
148205 try {
149206 const allowed = await onPermissionRequest ( toolName , inputStr ) ;
150207 if ( allowed ) {
151- return { behavior : " allow" , updatedInput : input } ;
208+ return { behavior : ' allow' , updatedInput : input } ;
152209 }
153210 return {
154- behavior : " deny" ,
155- message : " Permission denied by user." ,
156- interrupt : true ,
211+ behavior : ' deny' ,
212+ message : ' Permission denied by user.' ,
213+ interrupt : true
157214 } ;
158215 } catch ( err ) {
159- logger . error ( " Permission handler error" , { toolName, err } ) ;
216+ logger . error ( ' Permission handler error' , { toolName, err } ) ;
160217 return {
161- behavior : " deny" ,
162- message : " Permission check failed." ,
163- interrupt : true ,
218+ behavior : ' deny' ,
219+ message : ' Permission check failed.' ,
220+ interrupt : true
164221 } ;
165222 }
166223 } ;
167224 sdkOptions . canUseTool = canUseTool ;
168225 }
169226
170227 // --- Execute query & accumulate output ---
171- let sessionId = "" ;
228+ let sessionId = '' ;
172229 const textParts : string [ ] = [ ] ;
173230 let errorMessage : string | undefined ;
174231
@@ -180,31 +237,31 @@ export async function claudeQuery(options: QueryOptions): Promise<QueryResult> {
180237 if ( sid ) sessionId = sid ;
181238
182239 switch ( message . type ) {
183- case " assistant" : {
240+ case ' assistant' : {
184241 const text = extractText ( message as SDKAssistantMessage ) ;
185242 if ( text ) textParts . push ( text ) ;
186243 break ;
187244 }
188- case " result" : {
245+ case ' result' : {
189246 const rm = message as SDKResultMessage ;
190- if ( rm . subtype === " success" && " result" in rm ) {
247+ if ( rm . subtype === ' success' && ' result' in rm ) {
191248 // The SDK result message carries the final result string.
192249 // Append only when it adds content not yet seen.
193250 if ( rm . result ) {
194- const combined = textParts . join ( "" ) ;
251+ const combined = textParts . join ( '' ) ;
195252 if ( ! combined . includes ( rm . result ) ) {
196253 textParts . push ( rm . result ) ;
197254 }
198255 }
199- } else if ( " errors" in rm && rm . errors . length > 0 ) {
200- errorMessage = rm . errors . join ( "; " ) ;
201- logger . error ( " SDK returned error result" , { errors : rm . errors } ) ;
256+ } else if ( ' errors' in rm && rm . errors . length > 0 ) {
257+ errorMessage = rm . errors . join ( '; ' ) ;
258+ logger . error ( ' SDK returned error result' , { errors : rm . errors } ) ;
202259 }
203260 break ;
204261 }
205- case " system" : {
206- logger . debug ( " SDK system message" , {
207- subtype : ( message as { subtype ?: string } ) . subtype ,
262+ case ' system' : {
263+ logger . debug ( ' SDK system message' , {
264+ subtype : ( message as { subtype ?: string } ) . subtype
208265 } ) ;
209266 break ;
210267 }
@@ -215,24 +272,24 @@ export async function claudeQuery(options: QueryOptions): Promise<QueryResult> {
215272 }
216273 } catch ( err : unknown ) {
217274 errorMessage = err instanceof Error ? err . message : String ( err ) ;
218- logger . error ( " Claude query threw" , { error : errorMessage } ) ;
275+ logger . error ( ' Claude query threw' , { error : errorMessage } ) ;
219276 }
220277
221- const fullText = textParts . join ( "\n" ) . trim ( ) ;
278+ const fullText = textParts . join ( '\n' ) . trim ( ) ;
222279
223280 if ( ! fullText && ! errorMessage ) {
224- errorMessage = " Claude returned an empty response." ;
281+ errorMessage = ' Claude returned an empty response.' ;
225282 }
226283
227- logger . info ( " Claude query completed" , {
284+ logger . info ( ' Claude query completed' , {
228285 sessionId,
229286 textLength : fullText . length ,
230- hasError : ! ! errorMessage ,
287+ hasError : ! ! errorMessage
231288 } ) ;
232289
233290 return {
234291 text : fullText ,
235292 sessionId,
236- error : errorMessage ,
293+ error : errorMessage
237294 } ;
238295}
0 commit comments