@exreve/exk 1.0.15 → 1.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,57 @@
1
1
  import { query } from '@anthropic-ai/claude-agent-sdk';
2
2
  import { execSync, spawn } from 'child_process';
3
- import { existsSync, mkdirSync, readFileSync } from 'fs';
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
4
4
  import { symlink as fsSymlink } from 'fs';
5
- // Memory system removed for performance
6
5
  import { getSkillContent } from './skills/index.js';
6
+ import { isLocalModel, unwrapModelName, startOpenAIAdapter, getAdapterConfig } from './openaiAdapter.js';
7
7
  import { createModuleMcpServer } from './moduleMcpServer.js';
8
8
  import path from 'path';
9
9
  import os from 'os';
10
10
  import { createRequire } from 'module';
11
11
  import { promisify } from 'util';
12
+ // ============ Session State Persistence ============
13
+ // Persists claudeSessionId to disk so context survives CLI restarts.
14
+ // Files are stored in ~/.talk-to-code/session-state/<sessionId>.json
15
+ const SESSION_STATE_DIR = path.join(os.homedir(), '.talk-to-code', 'session-state');
16
+ function sessionStatePath(sessionId) {
17
+ return path.join(SESSION_STATE_DIR, `${sessionId}.json`);
18
+ }
19
+ function saveSessionState(sessionId, state) {
20
+ try {
21
+ if (!existsSync(SESSION_STATE_DIR)) {
22
+ mkdirSync(SESSION_STATE_DIR, { recursive: true });
23
+ }
24
+ writeFileSync(sessionStatePath(sessionId), JSON.stringify(state, null, 2));
25
+ }
26
+ catch (err) {
27
+ console.error(`[AgentSessionManager] Failed to persist session state for ${sessionId}:`, err);
28
+ }
29
+ }
30
+ function loadSessionState(sessionId) {
31
+ try {
32
+ const filePath = sessionStatePath(sessionId);
33
+ if (!existsSync(filePath))
34
+ return null;
35
+ const data = JSON.parse(readFileSync(filePath, 'utf-8'));
36
+ if (data && typeof data.claudeSessionId === 'string' && data.claudeSessionId) {
37
+ return data;
38
+ }
39
+ return null;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ function deleteSessionState(sessionId) {
46
+ try {
47
+ const filePath = sessionStatePath(sessionId);
48
+ if (existsSync(filePath))
49
+ unlinkSync(filePath);
50
+ }
51
+ catch {
52
+ // Ignore cleanup errors
53
+ }
54
+ }
12
55
  /**
13
56
  * Resolve path to the SDK's bundled cli.js.
14
57
  * We resolve this ourselves so it works reliably on Windows when running from
@@ -97,19 +140,52 @@ function loadAiConfig() {
97
140
  return { apiKey: '', baseUrl: '', model: DEFAULT_AI_MODEL };
98
141
  }
99
142
  }
100
- /** Env for the Claude Code child: copy of host env with host ANTHROPIC_* stripped, then inject from ai-config only. */
101
- function envForClaudeCodeChild() {
143
+ /** Create (or reuse) an empty directory to use as CLAUDE_CONFIG_DIR.
144
+ * Setting this prevents the spawned Claude CLI from reading ~/.claude/settings.json,
145
+ * which may contain env.ANTHROPIC_BASE_URL pointing to z.ai and would override our
146
+ * carefully configured base URL. */
147
+ function getEmptyConfigDir() {
148
+ const configDir = path.join(os.homedir(), '.talk-to-code', 'empty-config-dir');
149
+ if (!existsSync(configDir)) {
150
+ mkdirSync(configDir, { recursive: true });
151
+ }
152
+ return configDir;
153
+ }
154
+ /** Env for the Claude Code child: copy of host env with host ANTHROPIC_* stripped, then inject from ai-config only.
155
+ * If a local model is provided, override baseUrl to point to the anthropic-proxy adapter. */
156
+ function envForClaudeCodeChild(localModel) {
102
157
  const env = { ...process.env };
158
+ // Strip any host ANTHROPIC_* vars to prevent leaking credentials or wrong URLs
103
159
  delete env.ANTHROPIC_API_KEY;
104
160
  delete env.ANTHROPIC_BASE_URL;
105
161
  delete env.ANTHROPIC_AUTH_TOKEN;
162
+ // Inject from our ai-config.json only
106
163
  const { apiKey, baseUrl } = loadAiConfig();
107
164
  if (apiKey)
108
165
  env.ANTHROPIC_API_KEY = apiKey;
109
166
  if (baseUrl)
110
167
  env.ANTHROPIC_BASE_URL = baseUrl;
168
+ // Prevent ~/.claude/settings.json env section from overriding our base URL.
169
+ // This redirects the Claude config dir to an empty dir so that
170
+ // ~/.claude/settings.json (which may have ANTHROPIC_BASE_URL set to z.ai)
171
+ // is never read during the CLI process initialization.
172
+ env.CLAUDE_CONFIG_DIR = getEmptyConfigDir();
111
173
  return env;
112
174
  }
175
+ /** Get env overrides for a local model (adapter proxy URL + dummy key). Returns null if not a local model. */
176
+ async function getLocalModelEnvOverrides(model) {
177
+ if (!isLocalModel(model))
178
+ return null;
179
+ const adapterConfig = getAdapterConfig(model);
180
+ if (!adapterConfig)
181
+ return null;
182
+ const proxyUrl = await startOpenAIAdapter({
183
+ targetBaseUrl: adapterConfig.targetBaseUrl,
184
+ model: unwrapModelName(model),
185
+ apiKey: adapterConfig.apiKey,
186
+ });
187
+ return { baseUrl: proxyUrl, apiKey: 'local-no-key-needed' };
188
+ }
113
189
  // Lazy config getter - reloads from file each time (so daemon picks up changes without restart)
114
190
  const CLAUDE_CONFIG = {
115
191
  get apiKey() { return loadAiConfig().apiKey; },
@@ -146,60 +222,39 @@ export class AgentSessionManager {
146
222
  }
147
223
  }
148
224
  }
149
- // If session already exists, update the model if provided
225
+ // If session already exists, update mutable fields
150
226
  if (this.sessions.has(sessionId)) {
151
227
  const existingSession = this.sessions.get(sessionId);
152
228
  // Update model if a new one is provided
153
229
  if (model && model !== existingSession.model) {
154
230
  existingSession.model = model;
155
- // DISABLED: File logging removed for performance
156
- // if (existingSession.logger) {
157
- // await existingSession.logger.info(`Model updated to: ${model}`)
158
- // }
159
231
  }
160
- // DISABLED: File logging removed for performance
161
- // if (existingSession.logger) {
162
- // await existingSession.logger.info('Session already exists, reusing context')
163
- // }
164
- // Just ensure abort controller is fresh for new queries
232
+ // Update enabled modules and settings if provided
233
+ const newModules = handler.enabledModules;
234
+ if (newModules) {
235
+ existingSession.enabledModules = newModules;
236
+ }
237
+ const newModuleSettings = handler.moduleSettings;
238
+ if (newModuleSettings) {
239
+ existingSession.moduleSettings = newModuleSettings;
240
+ }
241
+ existingSession.userChoiceEnabled = handler.userChoiceEnabled || false;
242
+ // Ensure abort controller is fresh for new queries
165
243
  existingSession.abortController = new AbortController();
244
+ // Update handler to keep onChoiceRequest callback fresh
245
+ this.sessionHandlers.set(sessionId, handler);
166
246
  return;
167
247
  }
168
- // DISABLED: File logging removed for performance
169
- // const logger = new AgentLogger(sessionId)
170
- // await logger.logSessionCreated(projectPath)
171
- // await logger.logModelConfig(sessionModel, CLAUDE_CONFIG.baseUrl)
172
- const logger = undefined;
173
248
  // Store the handler for this session
174
249
  this.sessionHandlers.set(sessionId, handler);
175
250
  const abortController = new AbortController();
176
- // Create MCP server for modules if enabled modules are provided
177
- let mcpServer;
178
251
  const enabledModules = handler.enabledModules || [];
179
252
  const moduleSettings = handler.moduleSettings || {};
180
- if (enabledModules.length > 0) {
181
- mcpServer = createModuleMcpServer({
182
- enabledModules,
183
- moduleSettings,
184
- onChoiceRequest: handler.onChoiceRequest
185
- ? async (request) => {
186
- return new Promise((resolve) => {
187
- const session = this.sessions.get(sessionId);
188
- if (session) {
189
- session.pendingChoice = { request, resolve };
190
- handler.onChoiceRequest(request);
191
- }
192
- else {
193
- resolve({ choiceId: request.choiceId, selectedValue: null });
194
- }
195
- });
196
- }
197
- : undefined
198
- });
199
- // DISABLED: File logging removed for performance
200
- // if (logger) {
201
- // await logger.info(`MCP server created with modules: ${enabledModules.join(', ')}`)
202
- // }
253
+ // Restore claudeSessionId from disk (survives CLI restart)
254
+ const persistedState = loadSessionState(sessionId);
255
+ const restoredClaudeSessionId = persistedState?.claudeSessionId;
256
+ if (restoredClaudeSessionId) {
257
+ console.log(`[AgentSessionManager] Restored claudeSessionId for session ${sessionId}: ${restoredClaudeSessionId}`);
203
258
  }
204
259
  this.sessions.set(sessionId, {
205
260
  abortController,
@@ -209,35 +264,26 @@ export class AgentSessionManager {
209
264
  totalCostUsd: 0,
210
265
  promptQueue: [],
211
266
  isProcessingQueue: false,
212
- claudeSessionId: undefined, // Will be set when Claude SDK returns session ID
213
- // logger, // DISABLED: File logging removed for performance
214
- // Memory system removed for performance
267
+ claudeSessionId: restoredClaudeSessionId, // Restored from disk or undefined
215
268
  childProcesses: new Set(),
216
269
  claudeProcessGroupId: undefined,
217
270
  currentPromptId: undefined,
218
271
  model: sessionModel,
219
272
  userChoiceEnabled: handler.userChoiceEnabled || false,
220
273
  enabledModules,
221
- moduleSettings,
222
- mcpServer
274
+ moduleSettings
223
275
  });
224
276
  // Auto-regenerate CLAUDE.md for fresh project context
225
- // DISABLED: File logging removed for performance
226
- await this.regenerateClaudeMd(projectPath, undefined);
277
+ await this.regenerateClaudeMd(projectPath);
227
278
  }
228
279
  /**
229
280
  * Regenerate CLAUDE.md for fresh project context
230
281
  */
231
- async regenerateClaudeMd(projectPath, logger) {
282
+ async regenerateClaudeMd(projectPath) {
232
283
  try {
233
284
  const scriptPath = path.join(projectPath, 'scripts', 'generate-claude-md.js');
234
285
  if (existsSync(scriptPath)) {
235
- const startTime = Date.now();
236
286
  execSync(`node "${scriptPath}"`, { cwd: projectPath, stdio: 'ignore' });
237
- const duration = Date.now() - startTime;
238
- if (logger) {
239
- await logger.info(`CLAUDE.md regenerated in ${duration}ms`);
240
- }
241
287
  }
242
288
  }
243
289
  catch (error) {
@@ -245,7 +291,6 @@ export class AgentSessionManager {
245
291
  console.error('[AgentSessionManager] Failed to regenerate CLAUDE.md:', error);
246
292
  }
247
293
  }
248
- // Memory system removed for performance - no loadMemoryContext or storeConversationInMemory
249
294
  async sendPrompt(sessionId, prompt, enhancers = [], handler) {
250
295
  // Ensure session exists
251
296
  if (!this.sessions.has(sessionId)) {
@@ -255,10 +300,6 @@ export class AgentSessionManager {
255
300
  // Update session model if provided in handler
256
301
  if (handler.model && handler.model !== session.model) {
257
302
  session.model = handler.model;
258
- // DISABLED: File logging removed for performance
259
- // if (session.logger) {
260
- // await session.logger.info(`Model updated to: ${handler.model}`)
261
- // }
262
303
  }
263
304
  // Add prompt to queue - store promptId for cancellation
264
305
  session.promptQueue.push({
@@ -317,19 +358,12 @@ export class AgentSessionManager {
317
358
  // Emit status update: CLI is starting to process the prompt (IMMEDIATELY)
318
359
  // This ensures real-time status updates before any async operations
319
360
  onStatusUpdate?.('running');
320
- // DISABLED: File logging removed for performance
321
- // // Log prompt start
322
- // if (session.logger) {
323
- // await session.logger.logPromptStart(prompt, projectPath)
324
- // }
325
361
  // Wait for current query to finish before starting next prompt
326
- // REMOVED: 200ms artificial delay - stream draining is sufficient
327
362
  if (session.activeQueryStream !== undefined) {
328
363
  try {
329
364
  for await (const _ of session.activeQueryStream) { }
330
365
  }
331
366
  catch { }
332
- // REMOVED: await new Promise(resolve => setTimeout(resolve, 200))
333
367
  session.activeQueryStream = undefined;
334
368
  }
335
369
  session.activeQueryStream = undefined;
@@ -339,11 +373,6 @@ export class AgentSessionManager {
339
373
  const skillContent = getSkillContent(enhancers);
340
374
  if (skillContent) {
341
375
  finalPrompt = `${skillContent}\n\n${prompt}`;
342
- // DISABLED: File logging removed for performance
343
- // // Log that enhancers are being used
344
- // if (session.logger) {
345
- // await session.logger.info(`Using enhancers: ${enhancers.join(', ')}`)
346
- // }
347
376
  }
348
377
  }
349
378
  // Add user message to history
@@ -375,14 +404,6 @@ export class AgentSessionManager {
375
404
  });
376
405
  // Use cached Claude executable path (resolved at module load time for performance)
377
406
  const pathToClaudeCodeExecutable = CACHED_CLAUDE_PATH;
378
- // DISABLED: File logging removed for performance
379
- // if (session.logger) {
380
- // if (pathToClaudeCodeExecutable) {
381
- // session.logger.info(`Using Claude (cli.js): ${pathToClaudeCodeExecutable}`).catch(() => {})
382
- // } else {
383
- // session.logger.info('Using SDK built-in Claude (cli.js)').catch(() => {})
384
- // }
385
- // }
386
407
  // Build query options - include abort signal for cancellation
387
408
  const queryOptions = {
388
409
  signal: abortController.signal, // Pass abort signal to SDK for interruption
@@ -394,11 +415,18 @@ export class AgentSessionManager {
394
415
  settingSources: ['project'], // Enable CLAUDE.md loading
395
416
  permissionMode: 'bypassPermissions',
396
417
  allowDangerouslySkipPermissions: true,
397
- // Add MCP server for modules if configured
398
- ...(session.mcpServer ? { mcpServers: [session.mcpServer] } : {}),
418
+ // Create a fresh MCP server for each query call (SDK connects transport internally, cannot reuse)
419
+ ...(() => {
420
+ const mcpServer = this.buildMcpServer(sessionId);
421
+ return mcpServer ? { mcpServers: [mcpServer] } : {};
422
+ })(),
399
423
  ...(pathToClaudeCodeExecutable ? { pathToClaudeCodeExecutable } : {}),
400
424
  spawnClaudeCodeProcess: (spawnOptions) => {
401
425
  const { command, args, cwd: cwd2, env, signal } = spawnOptions;
426
+ // Debug: log what env/args are being passed to Claude process
427
+ console.log(`[agentSession] Spawn ANTHROPIC_BASE_URL:`, env?.ANTHROPIC_BASE_URL || '(not set)');
428
+ console.log(`[agentSession] Spawn ANTHROPIC_API_KEY:`, env?.ANTHROPIC_API_KEY ? '(set)' : '(not set)');
429
+ console.log(`[agentSession] Spawn args:`, args?.join(' '));
402
430
  // Only check file existence when command is a path (not a bare name like "claude" from PATH)
403
431
  const hasPathSep = command.includes(path.sep) || command.includes('/') || command.includes('\\');
404
432
  if (hasPathSep && !existsSync(command)) {
@@ -514,18 +542,51 @@ export class AgentSessionManager {
514
542
  };
515
543
  // Log model being used for debugging
516
544
  const sessionModel = session.model || CLAUDE_CONFIG.model;
517
- // DISABLED: File logging removed for performance
518
- // if (session.logger) {
519
- // await session.logger.logModelConfig(sessionModel, CLAUDE_CONFIG.baseUrl)
520
- // }
545
+ // Resolve local model adapter overrides (if using OpenAI-compatible endpoint)
546
+ let effectiveModel = sessionModel;
547
+ let effectiveApiKey = queryOptions.apiKey;
548
+ let effectiveEnv = queryOptions.env;
549
+ let effectiveSettings;
550
+ const localOverrides = await getLocalModelEnvOverrides(sessionModel);
551
+ if (localOverrides) {
552
+ effectiveModel = unwrapModelName(sessionModel);
553
+ effectiveApiKey = localOverrides.apiKey;
554
+ effectiveEnv = {
555
+ ...effectiveEnv,
556
+ ANTHROPIC_API_KEY: localOverrides.apiKey,
557
+ ANTHROPIC_BASE_URL: localOverrides.baseUrl,
558
+ };
559
+ // Override settings to prevent ~/.claude/settings.json env from overriding our proxy URL.
560
+ // Claude Code CLI reads settings.json → env section and applies those on top of spawn env,
561
+ // which would replace our ANTHROPIC_BASE_URL with the z.ai URL.
562
+ effectiveSettings = { env: { ANTHROPIC_API_KEY: localOverrides.apiKey, ANTHROPIC_BASE_URL: localOverrides.baseUrl } };
563
+ console.log(`[agentSession] Using local model adapter: ${sessionModel} -> ${localOverrides.baseUrl}`);
564
+ console.log(`[agentSession] effectiveSettings for local model:`, JSON.stringify(effectiveSettings));
565
+ }
566
+ else {
567
+ // Even for non-local models, ensure ~/.claude/settings.json env doesn't override our baseUrl
568
+ const aiConfig = loadAiConfig();
569
+ if (aiConfig.baseUrl || aiConfig.apiKey) {
570
+ const settingsEnv = {};
571
+ if (aiConfig.apiKey)
572
+ settingsEnv.ANTHROPIC_API_KEY = aiConfig.apiKey;
573
+ if (aiConfig.baseUrl)
574
+ settingsEnv.ANTHROPIC_BASE_URL = aiConfig.baseUrl;
575
+ effectiveSettings = { env: settingsEnv };
576
+ }
577
+ }
521
578
  // Create query stream - resume session if we have a Claude session ID
522
579
  // Always explicitly set model even when resuming to ensure we use the session's model
523
580
  const queryStream = query({
524
581
  prompt,
525
582
  options: {
526
583
  ...queryOptions,
527
- model: sessionModel, // Use session-specific model (supports switching between prompts)
528
- ...(session.claudeSessionId ? { resume: session.claudeSessionId } : {})
584
+ apiKey: effectiveApiKey,
585
+ model: effectiveModel,
586
+ env: effectiveEnv,
587
+ ...(effectiveSettings ? { settings: effectiveSettings } : {}),
588
+ ...(session.claudeSessionId && !localOverrides ? { resume: session.claudeSessionId } : {})
589
+ // Note: don't resume session for local models - context format differs
529
590
  }
530
591
  });
531
592
  session.activeQueryStream = queryStream;
@@ -554,10 +615,7 @@ export class AgentSessionManager {
554
615
  const systemMsg = message;
555
616
  if (systemMsg.session_id && !session.claudeSessionId) {
556
617
  session.claudeSessionId = systemMsg.session_id;
557
- // DISABLED: File logging removed for performance
558
- // if (session.logger) {
559
- // await session.logger.logClaudeSessionId(systemMsg.session_id)
560
- // }
618
+ saveSessionState(sessionId, { claudeSessionId: systemMsg.session_id, model: session.model, updatedAt: Date.now() });
561
619
  }
562
620
  }
563
621
  if (message.type === 'assistant') {
@@ -565,27 +623,13 @@ export class AgentSessionManager {
565
623
  // Capture Claude session ID from assistant message if not already set
566
624
  if (msg.session_id && !session.claudeSessionId) {
567
625
  session.claudeSessionId = msg.session_id;
568
- // DISABLED: File logging removed for performance
569
- // if (session.logger) {
570
- // await session.logger.logClaudeSessionId(msg.session_id)
571
- // }
626
+ saveSessionState(sessionId, { claudeSessionId: msg.session_id, model: session.model, updatedAt: Date.now() });
572
627
  }
573
628
  session.messages.push({
574
629
  role: 'assistant',
575
630
  content: msg.message,
576
631
  timestamp: Date.now()
577
632
  });
578
- // DISABLED: File logging removed for performance
579
- // // Log agent response
580
- // const responseText = typeof msg.message === 'string' ? msg.message : JSON.stringify(msg.message)
581
- // if (session.logger) {
582
- // await session.logger.logAgentResponse(responseText, {
583
- // uuid: msg.uuid,
584
- // sessionId: msg.session_id,
585
- // error: msg.error,
586
- // contextSize: session.messages.length
587
- // })
588
- // }
589
633
  onOutput({
590
634
  type: 'assistant',
591
635
  data: msg.message,
@@ -614,31 +658,7 @@ export class AgentSessionManager {
614
658
  outputTokens,
615
659
  totalTokens: inputTokens + outputTokens
616
660
  };
617
- // DISABLED: File logging removed for performance
618
- // // Log token usage
619
- // if (session.logger) {
620
- // await session.logger.logTokenUsage({
621
- // inputTokens: session.totalInputTokens,
622
- // outputTokens: session.totalOutputTokens,
623
- // totalTokens: session.totalInputTokens + session.totalOutputTokens,
624
- // costUsd: session.totalCostUsd
625
- // })
626
- // }
627
661
  }
628
- const promptDuration = Date.now() - promptStartTime;
629
- // DISABLED: File logging removed for performance
630
- // // Log prompt completion
631
- // if (session.logger) {
632
- // await session.logger.logPromptEnd(
633
- // msg.is_error ? 1 : 0,
634
- // promptDuration,
635
- // session.lastUsage ? {
636
- // inputTokens: session.lastUsage.inputTokens,
637
- // outputTokens: session.lastUsage.outputTokens,
638
- // costUsd: msg.total_cost_usd || 0
639
- // } : undefined
640
- // )
641
- // }
642
662
  onOutput({
643
663
  type: 'result',
644
664
  data: msg,
@@ -672,7 +692,6 @@ export class AgentSessionManager {
672
692
  onStatusUpdate?.(exitCode === 0 ? 'completed' : 'error');
673
693
  onComplete(exitCode);
674
694
  session.activeQueryStream = undefined;
675
- // Memory system removed for performance
676
695
  break; // Prompt complete, continue to next in queue
677
696
  }
678
697
  else if (message.type === 'user') {
@@ -872,14 +891,6 @@ export class AgentSessionManager {
872
891
  const currentSession = this.sessions.get(sessionId);
873
892
  if (currentSession) {
874
893
  currentSession.activeQueryStream = undefined;
875
- // DISABLED: File logging removed for performance
876
- // // Log error
877
- // if (currentSession.logger) {
878
- // await currentSession.logger.logSessionError(error, {
879
- // prompt: prompt.substring(0, 200),
880
- // projectPath
881
- // })
882
- // }
883
894
  }
884
895
  // Clean up abort controller (promptId is guaranteed to exist here due to check at line 204)
885
896
  if (promptId) {
@@ -920,6 +931,8 @@ export class AgentSessionManager {
920
931
  // Clear Claude session ID to start fresh
921
932
  session.claudeSessionId = undefined;
922
933
  session.lastUsage = undefined;
934
+ // Also clear persisted state
935
+ deleteSessionState(sessionId);
923
936
  }
924
937
  }
925
938
  async deleteSession(sessionId) {
@@ -929,6 +942,8 @@ export class AgentSessionManager {
929
942
  session.activeQueryStream = undefined;
930
943
  this.sessions.delete(sessionId);
931
944
  }
945
+ // Clean up persisted state
946
+ deleteSessionState(sessionId);
932
947
  }
933
948
  /**
934
949
  * Cancel a running or queued prompt by promptId
@@ -1106,6 +1121,43 @@ export class AgentSessionManager {
1106
1121
  return { success: false, message: error.message || 'Emergency stop failed' };
1107
1122
  }
1108
1123
  }
1124
+ /**
1125
+ * Build a fresh MCP server for a query call.
1126
+ * The SDK's query() connects the MCP server's internal transport, so we cannot
1127
+ * reuse a single instance across multiple queries. This must be called fresh each time.
1128
+ */
1129
+ buildMcpServer(sessionId) {
1130
+ const session = this.sessions.get(sessionId);
1131
+ if (!session) {
1132
+ console.log(`[buildMcpServer] No session found for ${sessionId}`);
1133
+ return undefined;
1134
+ }
1135
+ const enabledModules = session.enabledModules || [];
1136
+ console.log(`[buildMcpServer] Session ${sessionId}: enabledModules=${JSON.stringify(enabledModules)}`);
1137
+ if (enabledModules.length === 0) {
1138
+ console.log(`[buildMcpServer] No enabled modules, skipping MCP server creation`);
1139
+ return undefined;
1140
+ }
1141
+ const handler = this.sessionHandlers.get(sessionId);
1142
+ return createModuleMcpServer({
1143
+ enabledModules,
1144
+ moduleSettings: session.moduleSettings || {},
1145
+ onChoiceRequest: handler?.onChoiceRequest
1146
+ ? async (request) => {
1147
+ return new Promise((resolve) => {
1148
+ const sess = this.sessions.get(sessionId);
1149
+ if (sess) {
1150
+ sess.pendingChoice = { request, resolve };
1151
+ handler.onChoiceRequest(request);
1152
+ }
1153
+ else {
1154
+ resolve({ choiceId: request.choiceId, selectedValue: null });
1155
+ }
1156
+ });
1157
+ }
1158
+ : undefined
1159
+ });
1160
+ }
1109
1161
  /**
1110
1162
  * Handle user choice response from frontend
1111
1163
  */
@@ -0,0 +1,192 @@
1
+ /**
2
+ * OpenAI Adapter - Spawns an anthropic-proxy that translates Anthropic Messages API
3
+ * to OpenAI Chat Completions API. This allows Claude Agent SDK to talk to any
4
+ * OpenAI-compatible endpoint (Ollama, vLLM, local LLMs, etc.)
5
+ *
6
+ * Usage:
7
+ * import { startOpenAIAdapter, isLocalModel } from './openaiAdapter.js'
8
+ *
9
+ * if (isLocalModel(sessionModel)) {
10
+ * const proxyUrl = await startOpenAIAdapter({
11
+ * targetBaseUrl: 'http://192.168.1.101:3000',
12
+ * model: 'qwen3.5-27b',
13
+ * })
14
+ * // point ANTHROPIC_BASE_URL to proxyUrl
15
+ * }
16
+ */
17
+ import { spawn } from 'child_process';
18
+ import { createRequire } from 'module';
19
+ const VALID_LOCAL_PREFIXES = ['ollama:', 'openai:', 'local:'];
20
+ /** Check if a model ID refers to a local/OpenAI model that needs the adapter */
21
+ export function isLocalModel(model) {
22
+ return VALID_LOCAL_PREFIXES.some(p => model.startsWith(p));
23
+ }
24
+ /** Extract the actual model name by stripping the prefix (e.g. "ollama:qwen3:4b" -> "qwen3:4b") */
25
+ export function unwrapModelName(model) {
26
+ for (const prefix of VALID_LOCAL_PREFIXES) {
27
+ if (model.startsWith(prefix)) {
28
+ return model.slice(prefix.length);
29
+ }
30
+ }
31
+ return model;
32
+ }
33
+ // Track running proxies by target+model to reuse them
34
+ const runningProxies = new Map();
35
+ function getProxyKey(config) {
36
+ return `${config.targetBaseUrl}::${config.model}`;
37
+ }
38
+ /** Find a free port in the given range */
39
+ async function findFreePort(start, end) {
40
+ const net = await import('net');
41
+ return new Promise((resolve, reject) => {
42
+ function tryPort(port) {
43
+ if (port > end) {
44
+ reject(new Error(`No free port found in range ${start}-${end}`));
45
+ return;
46
+ }
47
+ const server = net.createServer();
48
+ server.listen(port, '127.0.0.1', () => {
49
+ const addr = server.address();
50
+ server.close(() => resolve(typeof addr === 'object' && addr ? addr.port : port));
51
+ });
52
+ server.on('error', () => tryPort(port + 1));
53
+ }
54
+ tryPort(start);
55
+ });
56
+ }
57
+ /**
58
+ * Start an anthropic-proxy instance for the given config.
59
+ * Returns the local proxy URL (e.g. http://127.0.0.1:8321) that speaks Anthropic Messages API.
60
+ *
61
+ * Proxies are reused - calling this twice with the same target+model returns the same URL.
62
+ */
63
+ export async function startOpenAIAdapter(config) {
64
+ const key = getProxyKey(config);
65
+ // Reuse existing proxy if running
66
+ const existing = runningProxies.get(key);
67
+ if (existing && !existing.process.killed) {
68
+ return existing.url;
69
+ }
70
+ const port = config.port || await findFreePort(8321, 8340);
71
+ // Resolve anthropic-proxy entry point
72
+ const req = typeof globalThis.require === 'function'
73
+ ? globalThis.require
74
+ : createRequire(import.meta.url);
75
+ let proxyPath;
76
+ try {
77
+ const pkgPath = req.resolve('anthropic-proxy/package.json');
78
+ proxyPath = pkgPath.replace(/package\.json$/, 'index.js');
79
+ }
80
+ catch {
81
+ throw new Error('anthropic-proxy package not found. Run: npm install anthropic-proxy');
82
+ }
83
+ const env = {
84
+ ...process.env,
85
+ ANTHROPIC_PROXY_BASE_URL: config.targetBaseUrl,
86
+ PORT: String(port),
87
+ REASONING_MODEL: config.model,
88
+ COMPLETION_MODEL: config.model,
89
+ // Suppress fastify logger noise
90
+ FASTIFY_LOG_LEVEL: 'error',
91
+ };
92
+ if (config.apiKey) {
93
+ env.OPENAI_API_KEY = config.apiKey;
94
+ }
95
+ const child = spawn('node', [proxyPath], {
96
+ env,
97
+ stdio: ['ignore', 'pipe', 'pipe'],
98
+ windowsHide: true,
99
+ });
100
+ // Suppress stdout/stderr but log errors
101
+ let stderrBuf = '';
102
+ child.stderr?.on('data', (data) => {
103
+ stderrBuf += data.toString();
104
+ // Only log actual errors, not fastify startup messages
105
+ if (stderrBuf.toLowerCase().includes('error') && !stderrBuf.includes('fastify')) {
106
+ console.error(`[openaiAdapter] Proxy stderr: ${stderrBuf}`);
107
+ }
108
+ stderrBuf = '';
109
+ });
110
+ child.on('exit', (code) => {
111
+ runningProxies.delete(key);
112
+ if (code && code !== 0) {
113
+ console.error(`[openaiAdapter] Proxy exited with code ${code}`);
114
+ }
115
+ });
116
+ const url = `http://127.0.0.1:${port}`;
117
+ // Wait for proxy to be ready (poll /v1/messages with a simple request)
118
+ const maxAttempts = 20;
119
+ for (let i = 0; i < maxAttempts; i++) {
120
+ await new Promise(r => setTimeout(r, 100));
121
+ try {
122
+ const resp = await fetch(`${url}/v1/messages`, {
123
+ method: 'POST',
124
+ headers: { 'Content-Type': 'application/json' },
125
+ body: JSON.stringify({
126
+ model: config.model,
127
+ max_tokens: 1,
128
+ messages: [{ role: 'user', content: 'ping' }],
129
+ }),
130
+ });
131
+ if (resp.ok || resp.status === 400 || resp.status === 422) {
132
+ // 400/422 means the proxy is up but the request was invalid - that's fine for a health check
133
+ runningProxies.set(key, { process: child, port, url });
134
+ console.log(`[openaiAdapter] Proxy ready at ${url} -> ${config.targetBaseUrl} (model: ${config.model})`);
135
+ return url;
136
+ }
137
+ }
138
+ catch {
139
+ // Not ready yet, retry
140
+ }
141
+ }
142
+ // If health check failed but process is still running, assume it's ready
143
+ if (!child.killed) {
144
+ runningProxies.set(key, { process: child, port, url });
145
+ console.log(`[openaiAdapter] Proxy started at ${url} (health check timed out, assuming ready)`);
146
+ return url;
147
+ }
148
+ throw new Error(`Failed to start anthropic-proxy for ${config.targetBaseUrl}`);
149
+ }
150
+ /** Stop all running proxy instances */
151
+ export function stopAllAdapters() {
152
+ for (const [key, { process }] of runningProxies) {
153
+ try {
154
+ if (!process.killed)
155
+ process.kill('SIGTERM');
156
+ }
157
+ catch { }
158
+ runningProxies.delete(key);
159
+ }
160
+ }
161
+ /**
162
+ * Get the adapter configuration for a local model.
163
+ * Reads from ~/.talk-to-code/openai-adapters.json or falls back to defaults.
164
+ */
165
+ export function getAdapterConfig(model) {
166
+ // Strip prefix to get the raw model name
167
+ const rawModel = unwrapModelName(model);
168
+ // Determine provider from prefix
169
+ if (model.startsWith('ollama:')) {
170
+ return { targetBaseUrl: 'http://127.0.0.1:11434' };
171
+ }
172
+ // For 'openai:' and 'local:' prefix, read from config file
173
+ try {
174
+ const req = typeof globalThis.require === 'function'
175
+ ? globalThis.require
176
+ : createRequire(import.meta.url);
177
+ const fs = req('fs');
178
+ const path = req('path');
179
+ const os = req('os');
180
+ const configPath = path.join(os.homedir(), '.talk-to-code', 'openai-adapters.json');
181
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
182
+ // Config format: { "default": { "baseUrl": "...", "apiKey": "..." }, ... }
183
+ const entry = config.default || config[rawModel];
184
+ if (entry?.baseUrl) {
185
+ return { targetBaseUrl: entry.baseUrl, apiKey: entry.apiKey };
186
+ }
187
+ }
188
+ catch (e) {
189
+ console.error('[openaiAdapter] Failed to read adapter config:', e);
190
+ }
191
+ return null;
192
+ }
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exreve/exk",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "description": "exk - Control Claude CLI with voice and programmable interfaces",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,6 +37,7 @@
37
37
  "@anthropic-ai/sdk": "^0.80.0",
38
38
  "@fastify/static": "^9.0.0",
39
39
  "@xenova/transformers": "^2.17.2",
40
+ "anthropic-proxy": "^1.3.0",
40
41
  "chokidar": "^3.6.0",
41
42
  "commander": "^13.1.0",
42
43
  "express": "^4.18.2",
@@ -47,8 +48,8 @@
47
48
  "uuid": "^11.0.3"
48
49
  },
49
50
  "devDependencies": {
50
- "@types/node": "^22.10.2",
51
51
  "@types/chokidar": "^2.1.3",
52
+ "@types/node": "^22.10.2",
52
53
  "@types/uuid": "^10.0.0",
53
54
  "@vercel/ncc": "^0.38.1",
54
55
  "esbuild": "^0.27.2",