@exreve/exk 1.0.16 → 1.0.18

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
@@ -91,25 +134,83 @@ function loadAiConfig() {
91
134
  const apiKey = typeof config.authToken === 'string' ? config.authToken.trim() : '';
92
135
  const baseUrl = typeof config.baseUrl === 'string' ? config.baseUrl.trim() : '';
93
136
  const model = typeof config.model === 'string' && config.model.trim() ? config.model.trim() : DEFAULT_AI_MODEL;
94
- return { apiKey, baseUrl, model };
137
+ const proxy = typeof config.proxy === 'string' ? config.proxy.trim() : '';
138
+ return { apiKey, baseUrl, model, proxy };
139
+ }
140
+ catch {
141
+ return { apiKey: '', baseUrl: '', model: DEFAULT_AI_MODEL, proxy: '' };
142
+ }
143
+ }
144
+ /** Create (or reuse) an empty directory to use as CLAUDE_CONFIG_DIR.
145
+ * Setting this prevents the spawned Claude CLI from reading ~/.claude/settings.json,
146
+ * which may contain env.ANTHROPIC_BASE_URL pointing to z.ai and would override our
147
+ * carefully configured base URL. */
148
+ function getEmptyConfigDir() {
149
+ const configDir = path.join(os.homedir(), '.talk-to-code', 'empty-config-dir');
150
+ if (!existsSync(configDir)) {
151
+ mkdirSync(configDir, { recursive: true });
152
+ }
153
+ return configDir;
154
+ }
155
+ const PROXY_CONFIG_PATH = path.join(os.homedir(), '.talk-to-code', 'proxy.json');
156
+ /** Read proxy toggle state from disk (synchronous) */
157
+ function readProxyToggle() {
158
+ try {
159
+ const data = readFileSync(PROXY_CONFIG_PATH, 'utf-8');
160
+ return JSON.parse(data);
95
161
  }
96
162
  catch {
97
- return { apiKey: '', baseUrl: '', model: DEFAULT_AI_MODEL };
163
+ return { enabled: false };
98
164
  }
99
165
  }
100
- /** Env for the Claude Code child: copy of host env with host ANTHROPIC_* stripped, then inject from ai-config only. */
101
- function envForClaudeCodeChild() {
166
+ /** Env for the Claude Code child: copy of host env with host ANTHROPIC_* stripped, then inject from ai-config only.
167
+ * If a local model is provided, override baseUrl to point to the anthropic-proxy adapter. */
168
+ function envForClaudeCodeChild(localModel) {
102
169
  const env = { ...process.env };
170
+ // Strip any host ANTHROPIC_* vars to prevent leaking credentials or wrong URLs
103
171
  delete env.ANTHROPIC_API_KEY;
104
172
  delete env.ANTHROPIC_BASE_URL;
105
173
  delete env.ANTHROPIC_AUTH_TOKEN;
106
- const { apiKey, baseUrl } = loadAiConfig();
174
+ // Inject from our ai-config.json only
175
+ const { apiKey, baseUrl, proxy } = loadAiConfig();
107
176
  if (apiKey)
108
177
  env.ANTHROPIC_API_KEY = apiKey;
109
178
  if (baseUrl)
110
179
  env.ANTHROPIC_BASE_URL = baseUrl;
180
+ // Apply proxy if enabled
181
+ const proxyToggle = readProxyToggle();
182
+ if (proxyToggle.enabled && proxy) {
183
+ env.HTTPS_PROXY = proxy;
184
+ env.HTTP_PROXY = proxy;
185
+ }
186
+ else {
187
+ // Clear any inherited proxy env
188
+ delete env.HTTPS_PROXY;
189
+ delete env.HTTP_PROXY;
190
+ delete env.https_proxy;
191
+ delete env.http_proxy;
192
+ }
193
+ // Prevent ~/.claude/settings.json env section from overriding our base URL.
194
+ // This redirects the Claude config dir to an empty dir so that
195
+ // ~/.claude/settings.json (which may have ANTHROPIC_BASE_URL set to z.ai)
196
+ // is never read during the CLI process initialization.
197
+ env.CLAUDE_CONFIG_DIR = getEmptyConfigDir();
111
198
  return env;
112
199
  }
200
+ /** Get env overrides for a local model (adapter proxy URL + dummy key). Returns null if not a local model. */
201
+ async function getLocalModelEnvOverrides(model) {
202
+ if (!isLocalModel(model))
203
+ return null;
204
+ const adapterConfig = getAdapterConfig(model);
205
+ if (!adapterConfig)
206
+ return null;
207
+ const proxyUrl = await startOpenAIAdapter({
208
+ targetBaseUrl: adapterConfig.targetBaseUrl,
209
+ model: unwrapModelName(model),
210
+ apiKey: adapterConfig.apiKey,
211
+ });
212
+ return { baseUrl: proxyUrl, apiKey: 'local-no-key-needed' };
213
+ }
113
214
  // Lazy config getter - reloads from file each time (so daemon picks up changes without restart)
114
215
  const CLAUDE_CONFIG = {
115
216
  get apiKey() { return loadAiConfig().apiKey; },
@@ -146,60 +247,39 @@ export class AgentSessionManager {
146
247
  }
147
248
  }
148
249
  }
149
- // If session already exists, update the model if provided
250
+ // If session already exists, update mutable fields
150
251
  if (this.sessions.has(sessionId)) {
151
252
  const existingSession = this.sessions.get(sessionId);
152
253
  // Update model if a new one is provided
153
254
  if (model && model !== existingSession.model) {
154
255
  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
256
  }
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
257
+ // Update enabled modules and settings if provided
258
+ const newModules = handler.enabledModules;
259
+ if (newModules) {
260
+ existingSession.enabledModules = newModules;
261
+ }
262
+ const newModuleSettings = handler.moduleSettings;
263
+ if (newModuleSettings) {
264
+ existingSession.moduleSettings = newModuleSettings;
265
+ }
266
+ existingSession.userChoiceEnabled = handler.userChoiceEnabled || false;
267
+ // Ensure abort controller is fresh for new queries
165
268
  existingSession.abortController = new AbortController();
269
+ // Update handler to keep onChoiceRequest callback fresh
270
+ this.sessionHandlers.set(sessionId, handler);
166
271
  return;
167
272
  }
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
273
  // Store the handler for this session
174
274
  this.sessionHandlers.set(sessionId, handler);
175
275
  const abortController = new AbortController();
176
- // Create MCP server for modules if enabled modules are provided
177
- let mcpServer;
178
276
  const enabledModules = handler.enabledModules || [];
179
277
  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
- // }
278
+ // Restore claudeSessionId from disk (survives CLI restart)
279
+ const persistedState = loadSessionState(sessionId);
280
+ const restoredClaudeSessionId = persistedState?.claudeSessionId;
281
+ if (restoredClaudeSessionId) {
282
+ console.log(`[AgentSessionManager] Restored claudeSessionId for session ${sessionId}: ${restoredClaudeSessionId}`);
203
283
  }
204
284
  this.sessions.set(sessionId, {
205
285
  abortController,
@@ -209,35 +289,26 @@ export class AgentSessionManager {
209
289
  totalCostUsd: 0,
210
290
  promptQueue: [],
211
291
  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
292
+ claudeSessionId: restoredClaudeSessionId, // Restored from disk or undefined
215
293
  childProcesses: new Set(),
216
294
  claudeProcessGroupId: undefined,
217
295
  currentPromptId: undefined,
218
296
  model: sessionModel,
219
297
  userChoiceEnabled: handler.userChoiceEnabled || false,
220
298
  enabledModules,
221
- moduleSettings,
222
- mcpServer
299
+ moduleSettings
223
300
  });
224
301
  // Auto-regenerate CLAUDE.md for fresh project context
225
- // DISABLED: File logging removed for performance
226
- await this.regenerateClaudeMd(projectPath, undefined);
302
+ await this.regenerateClaudeMd(projectPath);
227
303
  }
228
304
  /**
229
305
  * Regenerate CLAUDE.md for fresh project context
230
306
  */
231
- async regenerateClaudeMd(projectPath, logger) {
307
+ async regenerateClaudeMd(projectPath) {
232
308
  try {
233
309
  const scriptPath = path.join(projectPath, 'scripts', 'generate-claude-md.js');
234
310
  if (existsSync(scriptPath)) {
235
- const startTime = Date.now();
236
311
  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
312
  }
242
313
  }
243
314
  catch (error) {
@@ -245,7 +316,6 @@ export class AgentSessionManager {
245
316
  console.error('[AgentSessionManager] Failed to regenerate CLAUDE.md:', error);
246
317
  }
247
318
  }
248
- // Memory system removed for performance - no loadMemoryContext or storeConversationInMemory
249
319
  async sendPrompt(sessionId, prompt, enhancers = [], handler) {
250
320
  // Ensure session exists
251
321
  if (!this.sessions.has(sessionId)) {
@@ -255,10 +325,6 @@ export class AgentSessionManager {
255
325
  // Update session model if provided in handler
256
326
  if (handler.model && handler.model !== session.model) {
257
327
  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
328
  }
263
329
  // Add prompt to queue - store promptId for cancellation
264
330
  session.promptQueue.push({
@@ -317,19 +383,12 @@ export class AgentSessionManager {
317
383
  // Emit status update: CLI is starting to process the prompt (IMMEDIATELY)
318
384
  // This ensures real-time status updates before any async operations
319
385
  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
386
  // Wait for current query to finish before starting next prompt
326
- // REMOVED: 200ms artificial delay - stream draining is sufficient
327
387
  if (session.activeQueryStream !== undefined) {
328
388
  try {
329
389
  for await (const _ of session.activeQueryStream) { }
330
390
  }
331
391
  catch { }
332
- // REMOVED: await new Promise(resolve => setTimeout(resolve, 200))
333
392
  session.activeQueryStream = undefined;
334
393
  }
335
394
  session.activeQueryStream = undefined;
@@ -339,11 +398,6 @@ export class AgentSessionManager {
339
398
  const skillContent = getSkillContent(enhancers);
340
399
  if (skillContent) {
341
400
  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
401
  }
348
402
  }
349
403
  // Add user message to history
@@ -375,14 +429,6 @@ export class AgentSessionManager {
375
429
  });
376
430
  // Use cached Claude executable path (resolved at module load time for performance)
377
431
  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
432
  // Build query options - include abort signal for cancellation
387
433
  const queryOptions = {
388
434
  signal: abortController.signal, // Pass abort signal to SDK for interruption
@@ -394,11 +440,18 @@ export class AgentSessionManager {
394
440
  settingSources: ['project'], // Enable CLAUDE.md loading
395
441
  permissionMode: 'bypassPermissions',
396
442
  allowDangerouslySkipPermissions: true,
397
- // Add MCP server for modules if configured
398
- ...(session.mcpServer ? { mcpServers: [session.mcpServer] } : {}),
443
+ // Create a fresh MCP server for each query call (SDK connects transport internally, cannot reuse)
444
+ ...(() => {
445
+ const mcpServer = this.buildMcpServer(sessionId);
446
+ return mcpServer ? { mcpServers: [mcpServer] } : {};
447
+ })(),
399
448
  ...(pathToClaudeCodeExecutable ? { pathToClaudeCodeExecutable } : {}),
400
449
  spawnClaudeCodeProcess: (spawnOptions) => {
401
450
  const { command, args, cwd: cwd2, env, signal } = spawnOptions;
451
+ // Debug: log what env/args are being passed to Claude process
452
+ console.log(`[agentSession] Spawn ANTHROPIC_BASE_URL:`, env?.ANTHROPIC_BASE_URL || '(not set)');
453
+ console.log(`[agentSession] Spawn ANTHROPIC_API_KEY:`, env?.ANTHROPIC_API_KEY ? '(set)' : '(not set)');
454
+ console.log(`[agentSession] Spawn args:`, args?.join(' '));
402
455
  // Only check file existence when command is a path (not a bare name like "claude" from PATH)
403
456
  const hasPathSep = command.includes(path.sep) || command.includes('/') || command.includes('\\');
404
457
  if (hasPathSep && !existsSync(command)) {
@@ -514,18 +567,51 @@ export class AgentSessionManager {
514
567
  };
515
568
  // Log model being used for debugging
516
569
  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
- // }
570
+ // Resolve local model adapter overrides (if using OpenAI-compatible endpoint)
571
+ let effectiveModel = sessionModel;
572
+ let effectiveApiKey = queryOptions.apiKey;
573
+ let effectiveEnv = queryOptions.env;
574
+ let effectiveSettings;
575
+ const localOverrides = await getLocalModelEnvOverrides(sessionModel);
576
+ if (localOverrides) {
577
+ effectiveModel = unwrapModelName(sessionModel);
578
+ effectiveApiKey = localOverrides.apiKey;
579
+ effectiveEnv = {
580
+ ...effectiveEnv,
581
+ ANTHROPIC_API_KEY: localOverrides.apiKey,
582
+ ANTHROPIC_BASE_URL: localOverrides.baseUrl,
583
+ };
584
+ // Override settings to prevent ~/.claude/settings.json env from overriding our proxy URL.
585
+ // Claude Code CLI reads settings.json → env section and applies those on top of spawn env,
586
+ // which would replace our ANTHROPIC_BASE_URL with the z.ai URL.
587
+ effectiveSettings = { env: { ANTHROPIC_API_KEY: localOverrides.apiKey, ANTHROPIC_BASE_URL: localOverrides.baseUrl } };
588
+ console.log(`[agentSession] Using local model adapter: ${sessionModel} -> ${localOverrides.baseUrl}`);
589
+ console.log(`[agentSession] effectiveSettings for local model:`, JSON.stringify(effectiveSettings));
590
+ }
591
+ else {
592
+ // Even for non-local models, ensure ~/.claude/settings.json env doesn't override our baseUrl
593
+ const aiConfig = loadAiConfig();
594
+ if (aiConfig.baseUrl || aiConfig.apiKey) {
595
+ const settingsEnv = {};
596
+ if (aiConfig.apiKey)
597
+ settingsEnv.ANTHROPIC_API_KEY = aiConfig.apiKey;
598
+ if (aiConfig.baseUrl)
599
+ settingsEnv.ANTHROPIC_BASE_URL = aiConfig.baseUrl;
600
+ effectiveSettings = { env: settingsEnv };
601
+ }
602
+ }
521
603
  // Create query stream - resume session if we have a Claude session ID
522
604
  // Always explicitly set model even when resuming to ensure we use the session's model
523
605
  const queryStream = query({
524
606
  prompt,
525
607
  options: {
526
608
  ...queryOptions,
527
- model: sessionModel, // Use session-specific model (supports switching between prompts)
528
- ...(session.claudeSessionId ? { resume: session.claudeSessionId } : {})
609
+ apiKey: effectiveApiKey,
610
+ model: effectiveModel,
611
+ env: effectiveEnv,
612
+ ...(effectiveSettings ? { settings: effectiveSettings } : {}),
613
+ ...(session.claudeSessionId && !localOverrides ? { resume: session.claudeSessionId } : {})
614
+ // Note: don't resume session for local models - context format differs
529
615
  }
530
616
  });
531
617
  session.activeQueryStream = queryStream;
@@ -554,10 +640,7 @@ export class AgentSessionManager {
554
640
  const systemMsg = message;
555
641
  if (systemMsg.session_id && !session.claudeSessionId) {
556
642
  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
- // }
643
+ saveSessionState(sessionId, { claudeSessionId: systemMsg.session_id, model: session.model, updatedAt: Date.now() });
561
644
  }
562
645
  }
563
646
  if (message.type === 'assistant') {
@@ -565,27 +648,13 @@ export class AgentSessionManager {
565
648
  // Capture Claude session ID from assistant message if not already set
566
649
  if (msg.session_id && !session.claudeSessionId) {
567
650
  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
- // }
651
+ saveSessionState(sessionId, { claudeSessionId: msg.session_id, model: session.model, updatedAt: Date.now() });
572
652
  }
573
653
  session.messages.push({
574
654
  role: 'assistant',
575
655
  content: msg.message,
576
656
  timestamp: Date.now()
577
657
  });
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
658
  onOutput({
590
659
  type: 'assistant',
591
660
  data: msg.message,
@@ -614,31 +683,7 @@ export class AgentSessionManager {
614
683
  outputTokens,
615
684
  totalTokens: inputTokens + outputTokens
616
685
  };
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
686
  }
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
687
  onOutput({
643
688
  type: 'result',
644
689
  data: msg,
@@ -672,7 +717,6 @@ export class AgentSessionManager {
672
717
  onStatusUpdate?.(exitCode === 0 ? 'completed' : 'error');
673
718
  onComplete(exitCode);
674
719
  session.activeQueryStream = undefined;
675
- // Memory system removed for performance
676
720
  break; // Prompt complete, continue to next in queue
677
721
  }
678
722
  else if (message.type === 'user') {
@@ -872,14 +916,6 @@ export class AgentSessionManager {
872
916
  const currentSession = this.sessions.get(sessionId);
873
917
  if (currentSession) {
874
918
  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
919
  }
884
920
  // Clean up abort controller (promptId is guaranteed to exist here due to check at line 204)
885
921
  if (promptId) {
@@ -920,6 +956,8 @@ export class AgentSessionManager {
920
956
  // Clear Claude session ID to start fresh
921
957
  session.claudeSessionId = undefined;
922
958
  session.lastUsage = undefined;
959
+ // Also clear persisted state
960
+ deleteSessionState(sessionId);
923
961
  }
924
962
  }
925
963
  async deleteSession(sessionId) {
@@ -929,6 +967,8 @@ export class AgentSessionManager {
929
967
  session.activeQueryStream = undefined;
930
968
  this.sessions.delete(sessionId);
931
969
  }
970
+ // Clean up persisted state
971
+ deleteSessionState(sessionId);
932
972
  }
933
973
  /**
934
974
  * Cancel a running or queued prompt by promptId
@@ -1106,6 +1146,43 @@ export class AgentSessionManager {
1106
1146
  return { success: false, message: error.message || 'Emergency stop failed' };
1107
1147
  }
1108
1148
  }
1149
+ /**
1150
+ * Build a fresh MCP server for a query call.
1151
+ * The SDK's query() connects the MCP server's internal transport, so we cannot
1152
+ * reuse a single instance across multiple queries. This must be called fresh each time.
1153
+ */
1154
+ buildMcpServer(sessionId) {
1155
+ const session = this.sessions.get(sessionId);
1156
+ if (!session) {
1157
+ console.log(`[buildMcpServer] No session found for ${sessionId}`);
1158
+ return undefined;
1159
+ }
1160
+ const enabledModules = session.enabledModules || [];
1161
+ console.log(`[buildMcpServer] Session ${sessionId}: enabledModules=${JSON.stringify(enabledModules)}`);
1162
+ if (enabledModules.length === 0) {
1163
+ console.log(`[buildMcpServer] No enabled modules, skipping MCP server creation`);
1164
+ return undefined;
1165
+ }
1166
+ const handler = this.sessionHandlers.get(sessionId);
1167
+ return createModuleMcpServer({
1168
+ enabledModules,
1169
+ moduleSettings: session.moduleSettings || {},
1170
+ onChoiceRequest: handler?.onChoiceRequest
1171
+ ? async (request) => {
1172
+ return new Promise((resolve) => {
1173
+ const sess = this.sessions.get(sessionId);
1174
+ if (sess) {
1175
+ sess.pendingChoice = { request, resolve };
1176
+ handler.onChoiceRequest(request);
1177
+ }
1178
+ else {
1179
+ resolve({ choiceId: request.choiceId, selectedValue: null });
1180
+ }
1181
+ });
1182
+ }
1183
+ : undefined
1184
+ });
1185
+ }
1109
1186
  /**
1110
1187
  * Handle user choice response from frontend
1111
1188
  */
package/dist/index.js CHANGED
@@ -66,6 +66,33 @@ async function fetchAiConfig(authToken) {
66
66
  }
67
67
  }
68
68
  const AI_CONFIG_FILE = path.join(CONFIG_DIR, 'ai-config.json');
69
+ const PROXY_CONFIG_FILE = path.join(CONFIG_DIR, 'proxy.json');
70
+ /** Read proxy toggle state from disk */
71
+ async function readProxyConfig() {
72
+ try {
73
+ const raw = await fs.readFile(PROXY_CONFIG_FILE, 'utf-8');
74
+ return JSON.parse(raw);
75
+ }
76
+ catch {
77
+ return { enabled: false };
78
+ }
79
+ }
80
+ /** Write proxy toggle state to disk */
81
+ async function writeProxyConfig(cfg) {
82
+ await fs.mkdir(CONFIG_DIR, { recursive: true });
83
+ await fs.writeFile(PROXY_CONFIG_FILE, JSON.stringify(cfg, null, 2));
84
+ }
85
+ /** Get the proxy URL from ai-config.json (saved from backend) */
86
+ function getProxyUrl() {
87
+ try {
88
+ const raw = fsSync.readFileSync(AI_CONFIG_FILE, 'utf-8');
89
+ const j = JSON.parse(raw);
90
+ return typeof j.proxy === 'string' ? j.proxy.trim() : '';
91
+ }
92
+ catch {
93
+ return '';
94
+ }
95
+ }
69
96
  /** True if ai-config.json has a model API key (not read from host ANTHROPIC_* env). */
70
97
  function hasAiCredentials() {
71
98
  try {
@@ -1796,6 +1823,39 @@ async function runDaemon(foreground = false, email) {
1796
1823
  });
1797
1824
  });
1798
1825
  // Cloudflared handlers
1826
+ // Proxy toggle handler
1827
+ socket.on('proxy:toggle:request', async (data, callback) => {
1828
+ try {
1829
+ const { enable } = data;
1830
+ const proxyUrl = getProxyUrl();
1831
+ if (enable && !proxyUrl) {
1832
+ callback?.({ success: false, enabled: false });
1833
+ return;
1834
+ }
1835
+ await writeProxyConfig({ enabled: enable });
1836
+ if (foreground) {
1837
+ console.log(`[CLI] Proxy ${enable ? 'enabled' : 'disabled'}${proxyUrl ? `: ${proxyUrl}` : ''}`);
1838
+ }
1839
+ else {
1840
+ console.log(`Proxy ${enable ? 'enabled' : 'disabled'}`);
1841
+ }
1842
+ callback?.({ success: true, enabled: enable, proxyUrl: enable ? proxyUrl : undefined });
1843
+ }
1844
+ catch (error) {
1845
+ callback?.({ success: false, enabled: false });
1846
+ }
1847
+ });
1848
+ // Proxy status handler
1849
+ socket.on('proxy:status:request', async (_data, callback) => {
1850
+ try {
1851
+ const proxyConfig = await readProxyConfig();
1852
+ const proxyUrl = getProxyUrl();
1853
+ callback?.({ enabled: proxyConfig.enabled, proxyUrl: proxyConfig.enabled ? proxyUrl : undefined });
1854
+ }
1855
+ catch {
1856
+ callback?.({ enabled: false });
1857
+ }
1858
+ });
1799
1859
  socket.on('cloudflared:check:request', async () => {
1800
1860
  try {
1801
1861
  let installed = false;
@@ -2779,4 +2839,37 @@ program
2779
2839
  process.exit(1);
2780
2840
  });
2781
2841
  });
2842
+ // Enable proxy command
2843
+ program
2844
+ .command('enable')
2845
+ .description('Enable a feature (e.g. proxy)')
2846
+ .argument('<feature>', 'Feature to enable (proxy)')
2847
+ .action(async (feature) => {
2848
+ if (feature !== 'proxy') {
2849
+ console.error(`Unknown feature: ${feature}. Available: proxy`);
2850
+ process.exit(1);
2851
+ }
2852
+ const proxyUrl = getProxyUrl();
2853
+ if (!proxyUrl) {
2854
+ console.log('No proxy URL configured. Run "exk daemon" first to sync config from server.');
2855
+ process.exit(1);
2856
+ }
2857
+ await writeProxyConfig({ enabled: true });
2858
+ console.log(`Proxy enabled: ${proxyUrl}`);
2859
+ process.exit(0);
2860
+ });
2861
+ // Disable proxy command
2862
+ program
2863
+ .command('disable')
2864
+ .description('Disable a feature (e.g. proxy)')
2865
+ .argument('<feature>', 'Feature to disable (proxy)')
2866
+ .action(async (feature) => {
2867
+ if (feature !== 'proxy') {
2868
+ console.error(`Unknown feature: ${feature}. Available: proxy`);
2869
+ process.exit(1);
2870
+ }
2871
+ await writeProxyConfig({ enabled: false });
2872
+ console.log('Proxy disabled');
2873
+ process.exit(0);
2874
+ });
2782
2875
  program.parse();
@@ -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.16",
3
+ "version": "1.0.18",
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",