@@ -2,7 +2,7 @@ import { app, BrowserWindow, ipcMain, shell, dialog } from 'electron';
22import { electronApp } from '@electron-toolkit/utils' ;
33import { spawn as spawnPty } from 'node-pty' ;
44import { spawn as spawnChild } from 'node:child_process' ;
5- import { existsSync , mkdirSync , readFileSync , writeFileSync } from 'node:fs' ;
5+ import { existsSync , mkdirSync , readFileSync , writeFileSync , unlinkSync } from 'node:fs' ;
66import { join } from 'node:path' ;
77import { homedir } from 'node:os' ;
88
@@ -70,6 +70,22 @@ import { createWindow, watchWindowShortcuts, installAppMenu, mainWindow } from '
7070
7171const localGatewayManager = createLocalGatewayManager ( ) ;
7272
73+ // ── claude-code concurrent-session state ──────────────────────────────────────
74+ // claude-code reads ~/.claude/settings.json AFTER process.env, overriding any
75+ // env vars we inject. To allow multiple tabs to run claude-code simultaneously
76+ // with different providers, we use this strategy:
77+ //
78+ // 1. Each session writes a per-session temp settings file under ~/.claude/
79+ // and passes "--settings <tempFile>" so its provider env takes effect.
80+ // 2. The global ~/.claude/settings.json env section must not interfere, so
81+ // we clear it when the first session starts (ref count 0→1) and restore
82+ // the original when the last session exits (ref count 1→0).
83+ //
84+ // This means N concurrent claude-code tabs each get their own independent
85+ // provider config without fighting over the single global settings.json.
86+ const claudeSessionCount = { n : 0 } ;
87+ let claudeGlobalSettingsBackup : string | null = undefined as unknown as string | null ; // undefined = never read
88+
7389// ── CronJob scheduler ─────────────────────────────────────────────────────────
7490
7591function fireCronJob ( job : CronJob ) : void {
@@ -294,30 +310,42 @@ function registerIpc(): void {
294310 env = piLike . env ;
295311
296312 // ── claude-code provider override ─────────────────────────────────────
297- // claude-code re-injects env vars from ~/.claude/settings.json AFTER the
298- // process env is set, so any ANTHROPIC_BASE_URL in settings.json (e.g.
299- // pointing to a user's global DeepSeek config) silently overrides what
300- // tday sets via process.env or --settings.
313+ // claude-code reads ~/.claude/settings.json env section AFTER process.env,
314+ // silently overriding whatever we inject. We handle this with two layers:
315+ //
316+ // Layer 1 — per-session temp settings file:
317+ // Write our provider env to a per-session temp file
318+ // (~/.claude/tday-session-<tabId>.json) and pass --settings <file>.
319+ // This file is deleted when the PTY exits.
301320 //
302- // Strategy (following cc-switch): directly patch ~/.claude/settings.json
303- // before spawning. We save the original content first and restore it when
304- // the PTY exits, so the user's settings are always preserved.
321+ // Layer 2 — global env section cleared (ref-counted):
322+ // The global settings.json env section must not interfere with the
323+ // per-session temp file. On the first concurrent claude-code session
324+ // (ref count 0→1) we blank the env keys in the global file and save
325+ // the original. On the last session exit (ref count 1→0) we restore
326+ // the original. Multiple concurrent tabs are each isolated via their
327+ // own temp file while the global env stays silent.
305328 if ( req . agentId === 'claude-code' && effectiveProvider ) {
306329 const cp = effectiveProvider ;
307- // LM Studio and Ollama expose a native Anthropic-compatible endpoint at
308- // their root URL (e.g. http://localhost:1234), NOT at the /v1 sub-path
309- // that is used for their OpenAI-compat endpoint. The Anthropic SDK
310- // appends /v1/messages automatically, so we must strip the trailing
311- // /v1 that tday stores as the OpenAI base URL.
330+ // Resolve the correct Anthropic-compatible base URL per provider kind.
331+ // - LM Studio/Ollama: strip /v1 (native Anthropic endpoint at root)
332+ // - DeepSeek: strip /v1 then append /anthropic
333+ // - Others: use as-is (cloud providers already point to the right base)
312334 const LOCAL_OAI_COMPAT = new Set ( [ 'ollama' , 'lmstudio' , 'litellm' , 'vllm' , 'sglang' ] ) ;
313335 const rawUrl = cp . baseUrl ?? '' ;
314- const resolvedUrl = LOCAL_OAI_COMPAT . has ( cp . kind ?? '' )
315- ? rawUrl . replace ( / \/ v 1 \/ ? $ / , '' ) // http://localhost:1234/v1 → http://localhost:1234
316- : rawUrl ;
336+ let resolvedUrl : string ;
337+ if ( LOCAL_OAI_COMPAT . has ( cp . kind ?? '' ) ) {
338+ resolvedUrl = rawUrl . replace ( / \/ v 1 \/ ? $ / , '' ) ;
339+ } else if ( cp . kind === 'deepseek' ) {
340+ const base = rawUrl . replace ( / \/ $ / , '' ) . replace ( / \/ v 1 \/ ? $ / , '' ) ;
341+ resolvedUrl = base . endsWith ( '/anthropic' ) ? base : `${ base } /anthropic` ;
342+ } else {
343+ resolvedUrl = rawUrl ;
344+ }
317345 const apiKey = cp . apiKey ?? 'no-key-required' ;
318346
319- // Build the env section to inject . Model override keys are blanked so
320- // claude-code won't route to a stale model name from user's settings.
347+ // Env overrides for this session . Model keys are cleared so a stale
348+ // model name in the user's global settings won't take effect .
321349 const envPatch : Record < string , string > = {
322350 ANTHROPIC_MODEL : '' ,
323351 ANTHROPIC_DEFAULT_OPUS_MODEL : '' ,
@@ -335,84 +363,66 @@ function registerIpc(): void {
335363 envPatch . ANTHROPIC_AUTH_TOKEN = apiKey ;
336364 }
337365
338- // Patch ~/.claude/settings.json and schedule a restore on PTY exit.
339366 const claudeDir = join ( homedir ( ) , '.claude' ) ;
340- const claudeSettingsPath = join ( claudeDir , 'settings.json' ) ;
341- let originalSettings : string | null = null ;
367+ const globalSettingsPath = join ( claudeDir , 'settings.json' ) ;
368+ // Per-session temp settings file — unique per tab, never collides.
369+ const sessionSettingsPath = join ( claudeDir , `tday-session-${ req . tabId } .json` ) ;
370+
342371 try {
343372 mkdirSync ( claudeDir , { recursive : true } ) ;
344- // Save original so we can restore it after claude-code exits.
345- try { originalSettings = readFileSync ( claudeSettingsPath , 'utf8' ) ; } catch { /* file may not exist */ }
346- const existing : Record < string , unknown > =
347- originalSettings ? ( JSON . parse ( originalSettings ) as Record < string , unknown > ) : { } ;
348- const patched = {
349- ...existing ,
350- env : { ...( existing . env as Record < string , string > | undefined ?? { } ) , ...envPatch } ,
351- } ;
352- writeFileSync ( claudeSettingsPath , JSON . stringify ( patched , null , 2 ) , 'utf8' ) ;
373+
374+ // ── Layer 1: write per-session temp file ──────────────────────────
375+ const sessionSettings = { env : envPatch } ;
376+ writeFileSync ( sessionSettingsPath , JSON . stringify ( sessionSettings , null , 2 ) , 'utf8' ) ;
377+ // Instruct claude-code to load this file (merged with global).
378+ args = [ '--settings' , sessionSettingsPath , ...args ] ;
379+
380+ // ── Layer 2: blank global env (ref-counted) ───────────────────────
381+ if ( claudeSessionCount . n === 0 ) {
382+ // First session: capture the true original and clear global env.
383+ try { claudeGlobalSettingsBackup = readFileSync ( globalSettingsPath , 'utf8' ) ; }
384+ catch { claudeGlobalSettingsBackup = null ; }
385+
386+ const existing : Record < string , unknown > =
387+ claudeGlobalSettingsBackup
388+ ? ( JSON . parse ( claudeGlobalSettingsBackup ) as Record < string , unknown > )
389+ : { } ;
390+ const neutralized = { ...existing , env : { } } ;
391+ writeFileSync ( globalSettingsPath , JSON . stringify ( neutralized , null , 2 ) , 'utf8' ) ;
392+ }
393+ claudeSessionCount . n += 1 ;
353394 } catch ( e ) {
354- console . warn ( '[tday] could not patch ~/. claude/ settings.json :' , e ) ;
395+ console . warn ( '[tday] could not set up claude-code session settings:' , e ) ;
355396 }
356- // Store restore callback — used in pty.onExit below.
397+
398+ // Restore callback: clean up temp file and (last session) restore global.
357399 claudeSettingsRestore = ( ) => {
358400 try {
359- if ( originalSettings === null ) return ; // file didn't exist — nothing to restore
360- writeFileSync ( claudeSettingsPath , originalSettings , 'utf8' ) ;
401+ try { unlinkSync ( sessionSettingsPath ) ; } catch { /* ok */ }
402+ claudeSessionCount . n = Math . max ( 0 , claudeSessionCount . n - 1 ) ;
403+ if ( claudeSessionCount . n === 0 ) {
404+ // Last session done — restore global settings.json.
405+ if ( claudeGlobalSettingsBackup === null ) {
406+ // File didn't exist before; remove what we created.
407+ try { unlinkSync ( globalSettingsPath ) ; } catch { /* ok */ }
408+ } else if ( claudeGlobalSettingsBackup !== undefined ) {
409+ writeFileSync ( globalSettingsPath , claudeGlobalSettingsBackup , 'utf8' ) ;
410+ }
411+ claudeGlobalSettingsBackup = undefined as unknown as string | null ;
412+ }
361413 } catch ( e ) {
362- console . warn ( '[tday] could not restore ~/. claude/ settings.json :' , e ) ;
414+ console . warn ( '[tday] could not restore claude-code settings:' , e ) ;
363415 }
364416 } ;
365417
366- // 2. Also set the env vars in our process env for the child process
418+ // Also inject into process env for completeness.
367419 Object . assign ( env , envPatch ) ;
368420 }
369421
370422 if ( req . agentId === 'deepseek-tui' && effectiveProvider ) {
371423 Object . assign ( env , deepseekTuiProviderEnv ( effectiveProvider . kind , effectiveProvider . apiKey , effectiveProvider . baseUrl ) ) ;
372424 }
373425
374- // ── opencode provider override ─────────────────────────────────────────
375- // opencode (local providers) needs the provider registered in its config.
376- // Rather than mutating ~/.config/opencode/opencode.json, we use the
377- // OPENCODE_CONFIG_CONTENT env var which opencode merges as a mid-priority
378- // override (after global config, before project config). This tells
379- // opencode about the custom provider + its baseURL + available models.
380- if ( req . agentId === 'opencode' && effectiveProvider ) {
381- const cp = effectiveProvider ;
382- const OC_LOCAL = new Set ( [ 'ollama' , 'lmstudio' , 'litellm' , 'vllm' , 'sglang' ] ) ;
383- if ( cp . kind && OC_LOCAL . has ( cp . kind ) && cp . baseUrl ) {
384- // Collect all known model IDs, stripping any server-side prefix.
385- const modelSet = new Set < string > ( ) ;
386- const strip = ( m : string ) => m . includes ( '/' ) ? m . replace ( / ^ [ ^ / ] + \/ / , '' ) : m ;
387- if ( cp . model ) modelSet . add ( strip ( cp . model ) ) ;
388- for ( const m of cp . discoveredModels ?? [ ] ) modelSet . add ( strip ( m ) ) ;
389- for ( const m of cp . extraModels ?? [ ] ) modelSet . add ( strip ( m ) ) ;
390- if ( modelSet . size === 0 ) modelSet . add ( 'default' ) ;
391-
392- const modelsMap : Record < string , { name : string } > = { } ;
393- for ( const m of modelSet ) modelsMap [ m ] = { name : m } ;
394-
395- const DISPLAY : Record < string , string > = {
396- lmstudio : 'LM Studio (local)' ,
397- ollama : 'Ollama (local)' ,
398- litellm : 'LiteLLM (proxy)' ,
399- vllm : 'vLLM (local)' ,
400- sglang : 'SGLang (local)' ,
401- } ;
402- const providerDef : Record < string , unknown > = {
403- npm : '@ai-sdk/openai-compatible' ,
404- name : DISPLAY [ cp . kind ] ?? cp . kind ,
405- options : {
406- baseURL : cp . baseUrl ,
407- ...( cp . apiKey ? { apiKey : cp . apiKey } : { apiKey : 'no-key-required' } ) ,
408- } ,
409- models : modelsMap ,
410- } ;
411- const configContent = { provider : { [ cp . kind ] : providerDef } } ;
412- env . OPENCODE_CONFIG_CONTENT = JSON . stringify ( configContent ) ;
413- }
414- }
415-
416426 if ( gatewayResolution ?. noProxyHosts ?. length ) appendNoProxy ( env , gatewayResolution . noProxyHosts ) ;
417427 launchCwd = normalizeLaunchCwd ( piLike . cwd ) ;
418428 }
0 commit comments