Compare commits

..

4 Commits

Author SHA1 Message Date
L'électron rare ac04f97067 feat: add Mascarade as LLM provider — multi-agent orchestration engine
- Add 'mascarade' to SupportedProvider enum (types.ts)
- Add Mascarade case in createProviderInstance using OpenAI-compatible SDK (factory.ts)
- Add 'mascarade' to BuiltinProvider type (provider-account.ts)
- Add mascarade- prefix to MODEL_PROVIDER_MAP (config/types.ts)
- Add 5 mascarade models: router, writer, coder, analyst, planner (models.ts)
- Add mascarade provider presets for all agent profiles (models.ts)
- Add mascarade to PROVIDER_INFOS with UI metadata (providers.ts)

Mascarade is a self-hosted LLM orchestration engine (50K LOC Python)
that routes requests across Claude, OpenAI, Mistral, Google and Ollama
with intelligent routing strategies (best/cheapest/fastest/domain).
Default endpoint: http://localhost:8100/v1 (OpenAI-compatible)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 13:24:21 +01:00
AndyMik90 76fdbade6f docs: update README beta download links to 2.8.0-beta.5
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:32:15 +01:00
André Mikalsen 979d97757f fix(deps): update Vercel AI SDK and all dependencies (#1963)
* fix(deps): update Vercel AI SDK and all dependencies to latest

Updates 47 packages including all AI-related dependencies:
- ai (Vercel AI SDK): 6.0.91 → 6.0.116
- @ai-sdk/anthropic: 3.0.45 → 3.0.58
- @ai-sdk/mistral: 2.0.28 → 3.0.24 (major)
- @ai-sdk/google: 3.0.29 → 3.0.43
- @anthropic-ai/sdk: 0.71.2 → 0.78.0
- @modelcontextprotocol/sdk: 1.26.0 → 1.27.1
- zod: 4.2.1 → 4.3.6
- Plus all other @ai-sdk/* providers, UI, tooling deps

Skipped major bumps: electron 40→41, vite 7→8, jsdom 27→29

All 4487 tests pass, typecheck clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(deps): align @electron/rebuild override with devDependency (4.0.2 → 4.0.3)

Addresses PR review feedback from CodeRabbit, Gemini, and Cursor bots.
The override was still pinned to 4.0.2 while devDependency was bumped
to ^4.0.3, creating a version conflict.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:20:56 +01:00
André Mikalsen 3f8e16edb2 fix: skip Claude onboarding for profiles + prevent duplicate accounts (#1952)
* fix: skip Claude Code onboarding for authenticated profiles

When CLAUDE_CONFIG_DIR points to a profile directory, Claude Code reads
.claude.json from that directory instead of ~/.claude.json. Profile
configs created by `claude auth login` don't include hasCompletedOnboarding,
causing the onboarding wizard to appear every time Claude Code is launched.

Set hasCompletedOnboarding: true in the profile's .claude.json after
successful authentication and before each Claude Code invocation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: clean up dead onboarding code paths and add tests

Remove dead `needsOnboarding` UI branch from AuthTerminal.tsx and
stale type declarations from types.ts, ipc.ts, terminal-api.ts.
Remove unreachable `ensureOnboardingComplete` call from
`handleOnboardingComplete` (guard prevents execution). Export
`ensureOnboardingComplete` and add 9 unit tests covering all branches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: allow project-switching shortcuts when terminal is focused

xterm.js uses a hidden <textarea> (xterm-helper-textarea) for keyboard
input. ProjectTabBar's keydown handler skipped all HTMLTextAreaElement
targets, which prevented Cmd/Ctrl+1-9 project switching from working
when a terminal had focus. Exclude xterm's textarea from the skip-filter
since xterm already passes these shortcuts through via
attachCustomKeyEventHandler.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use atomic write to satisfy CodeQL insecure-temporary-file rule

Write .claude.json via temp file + rename instead of direct writeFileSync
to address CodeQL js/insecure-temporary-file false positive. The temp file
is created with mode 0o600 (owner-only) and atomically renamed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: prevent duplicate provider accounts and clean up dead onboarding API surface

Add backend gate in PROVIDER_ACCOUNTS_SAVE to reject duplicate email+provider
combinations with a user-friendly error. Clean up dead onTerminalOnboardingComplete
IPC surface (preload, types, constants, mock) that was never fired after the
onboarding flow was made proactive. Fix i18n compliance (hardcoded strings in
handleFallbackTerminal, orphaned translation keys) and Codex OAuth silent error
swallowing. Correct vi.mock paths in onboarding test file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 11:05:27 +01:00
27 changed files with 1306 additions and 1329 deletions
+6 -6
View File
@@ -42,12 +42,12 @@
<!-- BETA_DOWNLOADS -->
| Platform | Download |
|----------|----------|
| **Windows** | [Auto-Claude-2.8.0-beta.5-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Auto-Claude-2.8.0-beta.5-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Auto-Claude-2.8.0-beta.5-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Auto-Claude-2.8.0-beta.5-darwin-arm64.dmg) |
| **macOS (Intel)** | [Auto-Claude-2.8.0-beta.5-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Auto-Claude-2.8.0-beta.5-darwin-x64.dmg) |
| **Linux** | [Auto-Claude-2.8.0-beta.5-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Auto-Claude-2.8.0-beta.5-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Auto-Claude-2.8.0-beta.5-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Auto-Claude-2.8.0-beta.5-linux-amd64.deb) |
| **Linux (Flatpak)** | [Auto-Claude-2.8.0-beta.5-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Auto-Claude-2.8.0-beta.5-linux-x86_64.flatpak) |
| **Windows** | [Aperant-2.8.0-beta.5-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Aperant-2.8.0-beta.5-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-darwin-arm64.dmg) |
| **macOS (Intel)** | [Aperant-2.8.0-beta.5-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-darwin-x64.dmg) |
| **Linux** | [Aperant-2.8.0-beta.5-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Aperant-2.8.0-beta.5-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-linux-amd64.deb) |
| **Linux (Flatpak)** | [Aperant-2.8.0-beta.5-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-linux-x86_64.flatpak) |
<!-- BETA_DOWNLOADS_END -->
> All releases include SHA256 checksums and VirusTotal scan results for security verification.
+48 -48
View File
@@ -50,27 +50,27 @@
"typecheck": "tsc --noEmit --incremental"
},
"dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.61",
"@ai-sdk/anthropic": "^3.0.45",
"@ai-sdk/azure": "^3.0.31",
"@ai-sdk/google": "^3.0.29",
"@ai-sdk/groq": "^3.0.24",
"@ai-sdk/mcp": "^1.0.21",
"@ai-sdk/mistral": "^2.0.28",
"@ai-sdk/openai": "^3.0.30",
"@ai-sdk/openai-compatible": "^2.0.30",
"@ai-sdk/xai": "^3.0.57",
"@anthropic-ai/sdk": "^0.71.2",
"@ai-sdk/amazon-bedrock": "^4.0.77",
"@ai-sdk/anthropic": "^3.0.58",
"@ai-sdk/azure": "^3.0.42",
"@ai-sdk/google": "^3.0.43",
"@ai-sdk/groq": "^3.0.29",
"@ai-sdk/mcp": "^1.0.25",
"@ai-sdk/mistral": "^3.0.24",
"@ai-sdk/openai": "^3.0.41",
"@ai-sdk/openai-compatible": "^2.0.35",
"@ai-sdk/xai": "^3.0.67",
"@anthropic-ai/sdk": "^0.78.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@libsql/client": "^0.17.0",
"@lydell/node-pty": "^1.1.0",
"@modelcontextprotocol/sdk": "^1.26.0",
"@openrouter/ai-sdk-provider": "^2.2.3",
"@modelcontextprotocol/sdk": "^1.27.1",
"@openrouter/ai-sdk-provider": "^2.3.1",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15",
@@ -84,78 +84,78 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@sentry/electron": "^7.5.0",
"@sentry/electron": "^7.10.0",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-virtual": "^3.13.13",
"@tanstack/react-virtual": "^3.13.22",
"@tavily/core": "^0.7.2",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-serialize": "^0.14.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"ai": "^6.0.91",
"ai": "^6.0.116",
"chokidar": "^5.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^17.2.3",
"dotenv": "^17.3.1",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"i18next": "^25.7.3",
"lucide-react": "^0.562.0",
"minimatch": "^10.1.1",
"motion": "^12.23.26",
"electron-updater": "^6.8.3",
"i18next": "^25.8.18",
"lucide-react": "^0.577.0",
"minimatch": "^10.2.4",
"motion": "^12.36.0",
"proper-lockfile": "^4.1.2",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-i18next": "^16.5.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^16.5.8",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"semver": "^7.7.3",
"tailwind-merge": "^3.4.0",
"semver": "^7.7.4",
"tailwind-merge": "^3.5.0",
"uuid": "^13.0.0",
"web-tree-sitter": "^0.26.5",
"xstate": "^5.26.0",
"zod": "^4.2.1",
"zustand": "^5.0.9"
"web-tree-sitter": "^0.26.7",
"xstate": "^5.28.0",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
"devDependencies": {
"@biomejs/biome": "2.3.11",
"@biomejs/biome": "2.4.7",
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@electron/rebuild": "^4.0.2",
"@playwright/test": "^1.52.0",
"@tailwindcss/postcss": "^4.1.17",
"@testing-library/dom": "^10.0.0",
"@electron/rebuild": "^4.0.3",
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4.2.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.1.0",
"@testing-library/react": "^16.3.2",
"@types/minimatch": "^6.0.0",
"@types/node": "^25.0.0",
"@types/react": "^19.2.7",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/semver": "^7.7.1",
"@types/uuid": "^11.0.0",
"@vitejs/plugin-react": "^5.1.2",
"@vitest/coverage-v8": "^4.1.0",
"autoprefixer": "^10.4.22",
"autoprefixer": "^10.4.27",
"cross-env": "^10.1.0",
"electron": "40.0.0",
"electron-builder": "^26.4.0",
"electron-builder": "^26.8.1",
"electron-vite": "^5.0.0",
"husky": "^9.1.7",
"jsdom": "^27.3.0",
"lint-staged": "^16.2.7",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.17",
"lint-staged": "^16.4.0",
"postcss": "^8.5.8",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
"vite": "^7.2.7",
"vitest": "^4.0.16"
"vitest": "^4.1.0"
},
"overrides": {
"electron-builder-squirrel-windows": "^26.0.12",
"dmg-builder": "^26.0.12",
"@electron/rebuild": "4.0.2"
"@electron/rebuild": "4.0.3"
},
"build": {
"appId": "com.aperant.app",
@@ -67,8 +67,8 @@ vi.mock('../claude-profile/profile-utils', () => ({
getEmailFromConfigDir: vi.fn(),
}));
vi.mock('./output-parser', () => ({}));
vi.mock('./session-handler', () => ({}));
vi.mock('../terminal/output-parser', () => ({}));
vi.mock('../terminal/session-handler', () => ({}));
vi.mock('./pty-manager', () => ({
writeToPty: vi.fn(),
+1
View File
@@ -147,6 +147,7 @@ export const MODEL_PROVIDER_MAP: Record<string, SupportedProvider> = {
'llama-': 'groq',
'grok-': 'xai',
'glm-': 'zai',
'mascarade-': 'mascarade',
} as const;
// ============================================
@@ -156,6 +156,24 @@ function createProviderInstance(config: ProviderConfig) {
});
}
case SupportedProvider.Mascarade: {
// Mascarade LLM orchestration engine — OpenAI-compatible API
// Default: http://localhost:8100/v1 (Tower) or configurable
let mascaradeBaseURL = baseURL ?? 'http://localhost:8100/v1';
if (!mascaradeBaseURL.endsWith('/v1')) {
mascaradeBaseURL = mascaradeBaseURL.replace(/\/+$/, '') + '/v1';
}
return createOpenAICompatible({
name: 'mascarade',
apiKey: apiKey ?? 'mascarade-local',
baseURL: mascaradeBaseURL,
headers: {
...headers,
'Authorization': `Bearer ${apiKey ?? 'mascarade-local'}`,
},
});
}
default: {
const _exhaustive: never = provider;
throw new Error(`Unsupported provider: ${_exhaustive}`);
@@ -21,6 +21,7 @@ export const SupportedProvider = {
OpenRouter: 'openrouter',
ZAI: 'zai',
Ollama: 'ollama',
Mascarade: 'mascarade',
} as const;
export type SupportedProvider = (typeof SupportedProvider)[keyof typeof SupportedProvider];
+8 -104
View File
@@ -105,10 +105,6 @@ export class ClaudeProfileManager {
// This repairs emails that were truncated due to ANSI escape codes in terminal output
this.migrateCorruptedEmails();
// Deduplicate profiles that share the same email
// This cleans up duplicates created by repeated auth logins
this.deduplicateExistingProfiles();
// Populate missing subscription metadata for existing profiles
// This reads subscriptionType and rateLimitTier from Keychain credentials
this.populateSubscriptionMetadata();
@@ -210,68 +206,6 @@ export class ClaudeProfileManager {
}
}
/**
* Deduplicate existing profiles that share the same email.
* Keeps one profile per email, preferring: active > default > most recently created.
* Updates activeProfileId if the active profile was merged away.
*/
private deduplicateExistingProfiles(): void {
const emailMap = new Map<string, ClaudeProfile[]>();
for (const profile of this.data.profiles) {
if (!profile.email) continue;
const key = profile.email.toLowerCase();
const group = emailMap.get(key) || [];
group.push(profile);
emailMap.set(key, group);
}
const duplicateGroups = [...emailMap.values()].filter(g => g.length > 1);
if (duplicateGroups.length === 0) return;
const removedIds = new Set<string>();
for (const group of duplicateGroups) {
// Pick the winner: active profile > default > first in list
const winner =
group.find(p => p.id === this.data.activeProfileId) ||
group.find(p => p.isDefault) ||
group[0];
for (const profile of group) {
if (profile.id === winner.id) continue;
removedIds.add(profile.id);
}
// Merge: ensure winner has best metadata
if (!winner.isDefault) {
const defaultInGroup = group.find(p => p.isDefault);
if (defaultInGroup) winner.isDefault = true;
}
}
if (removedIds.size === 0) return;
const before = this.data.profiles.length;
this.data.profiles = this.data.profiles.filter(p => !removedIds.has(p.id));
// Fix activeProfileId if it was removed
if (this.data.activeProfileId && removedIds.has(this.data.activeProfileId)) {
const defaultProfile = this.data.profiles.find(p => p.isDefault);
this.data.activeProfileId = defaultProfile?.id || this.data.profiles[0]?.id;
}
// Clean up accountPriorityOrder references
if (this.data.accountPriorityOrder) {
this.data.accountPriorityOrder = this.data.accountPriorityOrder.filter(
id => !removedIds.has(id.replace('oauth-', ''))
);
}
this.save();
console.warn(`[ClaudeProfileManager] Deduplicated profiles: ${before}${this.data.profiles.length} (removed ${removedIds.size} duplicates)`);
}
/**
* Check if the profile manager has been initialized
*/
@@ -420,11 +354,7 @@ export class ClaudeProfileManager {
}
/**
* Save or update a profile.
*
* Deduplicates by email: if a profile with the same email already exists
* (different ID but same underlying account), the existing profile is updated
* instead of creating a duplicate entry.
* Save or update a profile
*/
saveProfile(profile: ClaudeProfile): ClaudeProfile {
// Expand ~ in configDir path
@@ -432,42 +362,16 @@ export class ClaudeProfileManager {
profile.configDir = expandHomePath(profile.configDir);
}
// First check: exact ID match (update existing profile)
const indexById = this.data.profiles.findIndex(p => p.id === profile.id);
const index = this.data.profiles.findIndex(p => p.id === profile.id);
if (indexById >= 0) {
// Update existing by ID
this.data.profiles[indexById] = profile;
this.save();
return profile;
if (index >= 0) {
// Update existing
this.data.profiles[index] = profile;
} else {
// Add new
this.data.profiles.push(profile);
}
// Second check: same email already exists (prevent duplicate)
if (profile.email) {
const emailLower = profile.email.toLowerCase();
const indexByEmail = this.data.profiles.findIndex(
p => p.email?.toLowerCase() === emailLower
);
if (indexByEmail >= 0) {
const existing = this.data.profiles[indexByEmail];
debugLog('[ClaudeProfileManager] Reusing existing profile for email:', profile.email,
'(existing ID:', existing.id, ', incoming ID:', profile.id, ')');
// Update the existing profile with fresh data, keeping its ID and default status
this.data.profiles[indexByEmail] = {
...profile,
id: existing.id,
isDefault: existing.isDefault || profile.isDefault,
name: profile.name || existing.name,
};
this.save();
return this.data.profiles[indexByEmail];
}
}
// No match — add new profile
this.data.profiles.push(profile);
this.save();
return profile;
}
@@ -1737,120 +1737,4 @@ describe('usage-monitor', () => {
expect(hasHardcodedText(' ')).toBe(true);
});
});
describe('deduplicateProfilesByEmail (private, tested via instance as any)', () => {
function makeProfile(overrides: Partial<import('../../shared/types/agent').ProfileUsageSummary> & { profileId: string }): import('../../shared/types/agent').ProfileUsageSummary {
return {
profileId: overrides.profileId,
profileName: overrides.profileName ?? 'Test Profile',
profileEmail: overrides.profileEmail,
sessionPercent: overrides.sessionPercent ?? 0,
weeklyPercent: overrides.weeklyPercent ?? 0,
isAuthenticated: overrides.isAuthenticated ?? true,
isRateLimited: overrides.isRateLimited ?? false,
availabilityScore: overrides.availabilityScore ?? 100,
isActive: overrides.isActive ?? false,
needsReauthentication: overrides.needsReauthentication ?? false,
};
}
let monitor: UsageMonitor;
beforeEach(() => {
monitor = UsageMonitor.getInstance();
});
it('should return empty array for empty input', () => {
const result = (monitor as any).deduplicateProfilesByEmail([]);
expect(result).toEqual([]);
});
it('should return all profiles when there are no duplicate emails', () => {
const profiles = [
makeProfile({ profileId: 'p1', profileEmail: 'alice@example.com' }),
makeProfile({ profileId: 'p2', profileEmail: 'bob@example.com' }),
makeProfile({ profileId: 'p3', profileEmail: 'carol@example.com' }),
];
const result = (monitor as any).deduplicateProfilesByEmail(profiles);
expect(result).toHaveLength(3);
expect(result.map((p: any) => p.profileId)).toEqual(['p1', 'p2', 'p3']);
});
it('should keep the higher-usage profile when two share the same email', () => {
const profiles = [
makeProfile({ profileId: 'p1', profileEmail: 'user@example.com', sessionPercent: 20, weeklyPercent: 30 }),
makeProfile({ profileId: 'p2', profileEmail: 'user@example.com', sessionPercent: 70, weeklyPercent: 50 }),
];
const result = (monitor as any).deduplicateProfilesByEmail(profiles);
expect(result).toHaveLength(1);
expect(result[0].profileId).toBe('p2');
});
it('should keep the first profile when two have the same max usage', () => {
const profiles = [
makeProfile({ profileId: 'p1', profileEmail: 'user@example.com', sessionPercent: 50, weeklyPercent: 40 }),
makeProfile({ profileId: 'p2', profileEmail: 'user@example.com', sessionPercent: 50, weeklyPercent: 40 }),
];
const result = (monitor as any).deduplicateProfilesByEmail(profiles);
expect(result).toHaveLength(1);
expect(result[0].profileId).toBe('p1');
});
it('should merge isActive flag: active profile with lower usage survives as active', () => {
const profiles = [
makeProfile({ profileId: 'p1', profileEmail: 'user@example.com', sessionPercent: 10, weeklyPercent: 10, isActive: true }),
makeProfile({ profileId: 'p2', profileEmail: 'user@example.com', sessionPercent: 80, weeklyPercent: 60, isActive: false }),
];
const result = (monitor as any).deduplicateProfilesByEmail(profiles);
expect(result).toHaveLength(1);
// p2 has higher usage, so it replaces p1, but isActive is merged from p1
expect(result[0].profileId).toBe('p2');
expect(result[0].isActive).toBe(true);
});
it('should merge needsReauthentication flag when replacing a profile', () => {
const profiles = [
makeProfile({ profileId: 'p1', profileEmail: 'user@example.com', sessionPercent: 10, needsReauthentication: true }),
makeProfile({ profileId: 'p2', profileEmail: 'user@example.com', sessionPercent: 80, needsReauthentication: false }),
];
const result = (monitor as any).deduplicateProfilesByEmail(profiles);
expect(result).toHaveLength(1);
expect(result[0].needsReauthentication).toBe(true);
});
it('should not deduplicate profiles with no email (uses profileId as key)', () => {
const profiles = [
makeProfile({ profileId: 'p1', profileEmail: undefined }),
makeProfile({ profileId: 'p2', profileEmail: undefined }),
];
const result = (monitor as any).deduplicateProfilesByEmail(profiles);
expect(result).toHaveLength(2);
});
it('should perform case-insensitive email matching', () => {
const profiles = [
makeProfile({ profileId: 'p1', profileEmail: 'User@Example.COM', sessionPercent: 10 }),
makeProfile({ profileId: 'p2', profileEmail: 'user@example.com', sessionPercent: 90 }),
];
const result = (monitor as any).deduplicateProfilesByEmail(profiles);
expect(result).toHaveLength(1);
expect(result[0].profileId).toBe('p2');
});
it('should handle mixed: some with emails, some without', () => {
const profiles = [
makeProfile({ profileId: 'p1', profileEmail: 'alice@example.com', sessionPercent: 20 }),
makeProfile({ profileId: 'p2', profileEmail: undefined }),
makeProfile({ profileId: 'p3', profileEmail: 'alice@example.com', sessionPercent: 60 }),
makeProfile({ profileId: 'p4', profileEmail: undefined }),
];
const result = (monitor as any).deduplicateProfilesByEmail(profiles);
// p1+p3 deduplicated (p3 wins), p2 and p4 are kept separately (no email)
expect(result).toHaveLength(3);
const ids = result.map((p: any) => p.profileId);
expect(ids).toContain('p3');
expect(ids).toContain('p2');
expect(ids).toContain('p4');
});
});
});
@@ -442,10 +442,6 @@ export class UsageMonitor extends EventEmitter {
// Include Z.AI provider accounts from providerAccounts
await this.appendZAIAccounts(allProfiles);
// Deduplicate by email (multiple profiles can share the same underlying account)
const deduped = this.deduplicateProfilesByEmail(allProfiles);
deduped.sort((a, b) => b.availabilityScore - a.availabilityScore);
// Return minimal data with auth status - don't return null!
return {
activeProfile: {
@@ -456,7 +452,7 @@ export class UsageMonitor extends EventEmitter {
fetchedAt: new Date(),
needsReauthentication: this.needsReauthProfiles.has(activeProfileId || '')
},
allProfiles: deduped,
allProfiles,
fetchedAt: new Date()
};
}
@@ -756,51 +752,17 @@ export class UsageMonitor extends EventEmitter {
// Include Z.AI provider accounts from providerAccounts
await this.appendZAIAccounts(allProfiles);
// Deduplicate by email (multiple profiles can share the same underlying account)
const deduplicatedProfiles = this.deduplicateProfilesByEmail(allProfiles);
// Sort by availability score (highest first = most available)
deduplicatedProfiles.sort((a, b) => b.availabilityScore - a.availabilityScore);
allProfiles.sort((a, b) => b.availabilityScore - a.availabilityScore);
return {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
activeProfile: this.currentUsage!, // Non-null: _doGetAllProfilesUsage is only called when currentUsage is set
allProfiles: deduplicatedProfiles,
allProfiles,
fetchedAt: new Date()
};
}
/**
* Deduplicate profiles by email. Multiple ClaudeProfileManager profiles can share
* the same email (different configDir pointing to the same underlying OAuth account).
* Keeps one entry per email, preferring the one with higher usage or active status.
*/
private deduplicateProfilesByEmail(profiles: ProfileUsageSummary[]): ProfileUsageSummary[] {
const result: ProfileUsageSummary[] = [];
const profileKeys = new Map<string, number>(); // email-or-profileId -> index in result
for (const profile of profiles) {
const key = profile.profileEmail?.toLowerCase();
if (key && profileKeys.has(key)) {
const existingIdx = profileKeys.get(key)!;
const existing = result[existingIdx];
const existingMax = Math.max(existing.sessionPercent, existing.weeklyPercent);
const currentMax = Math.max(profile.sessionPercent, profile.weeklyPercent);
if (currentMax > existingMax || (currentMax === existingMax && profile.isActive)) {
const mergedIsActive = existing.isActive || profile.isActive;
const mergedNeedsReauth = existing.needsReauthentication || profile.needsReauthentication;
result[existingIdx] = { ...profile, isActive: mergedIsActive, needsReauthentication: mergedNeedsReauth };
}
} else {
profileKeys.set(key ?? profile.profileId, result.length);
result.push(profile);
}
}
if (result.length < profiles.length) {
this.debugLog(`[UsageMonitor] Deduplicated profiles by email: ${profiles.length}${result.length}`);
}
return result;
}
/**
* Fetch usage for an inactive profile using its own credentials
* This allows showing real usage data for non-active profiles
@@ -933,6 +933,20 @@ export function registerSettingsHandlers(
try {
const settings = readSettingsFile() ?? {};
const accounts: ProviderAccount[] = (settings.providerAccounts as ProviderAccount[] | undefined) ?? [];
// Prevent duplicate: same email + provider already registered
if (account.email) {
const duplicate = accounts.find(
(a) => a.provider === account.provider && a.email?.toLowerCase() === account.email!.toLowerCase()
);
if (duplicate) {
return {
success: false,
error: `DUPLICATE_EMAIL:${duplicate.name}`,
};
}
}
const now = Date.now();
const newAccount: ProviderAccount = {
...account,
@@ -91,9 +91,6 @@ export interface TerminalAPI {
submitOAuthCode: (terminalId: string, code: string) => Promise<IPCResult>;
onTerminalClaudeBusy: (callback: (id: string, isBusy: boolean) => void) => () => void;
onTerminalClaudeExit: (callback: (id: string) => void) => () => void;
onTerminalOnboardingComplete: (
callback: (info: { terminalId: string; profileId?: string; detectedAt: string }) => void
) => () => void;
onTerminalPendingResume: (callback: (id: string, sessionId?: string) => void) => () => void;
onTerminalProfileChanged: (callback: (event: TerminalProfileChangedEvent) => void) => () => void;
@@ -388,21 +385,6 @@ export const createTerminalAPI = (): TerminalAPI => ({
};
},
onTerminalOnboardingComplete: (
callback: (info: { terminalId: string; profileId?: string; detectedAt: string }) => void
): (() => void) => {
const handler = (
_event: Electron.IpcRendererEvent,
info: { terminalId: string; profileId?: string; detectedAt: string }
): void => {
callback(info);
};
ipcRenderer.on(IPC_CHANNELS.TERMINAL_ONBOARDING_COMPLETE, handler);
return () => {
ipcRenderer.removeListener(IPC_CHANNELS.TERMINAL_ONBOARDING_COMPLETE, handler);
};
},
onTerminalPendingResume: (
callback: (id: string, sessionId?: string) => void
): (() => void) => {
@@ -400,11 +400,11 @@ export function GitHubSetupModal({
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4 max-h-[60vh] overflow-y-auto">
<div className="py-4 space-y-4">
<ProviderAccountsList />
{getProviderAccounts().length > 0 && (
<div className="flex items-center gap-2 rounded-lg bg-success/10 border border-success/30 p-3 sticky bottom-0">
<div className="flex items-center gap-2 rounded-lg bg-success/10 border border-success/30 p-3">
<CheckCircle2 className="h-4 w-4 text-success shrink-0" />
<p className="text-sm text-success">
{t('githubSetup.aiProviderReady')}
@@ -828,7 +828,7 @@ export function UsageIndicator() {
<PopoverContent
side="bottom"
align="end"
className="text-xs w-72 p-0 max-h-[80vh] overflow-y-auto"
className="text-xs w-72 p-0"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
@@ -959,7 +959,7 @@ export function UsageIndicator() {
<PopoverContent
side="bottom"
align="end"
className="text-xs w-72 p-0 max-h-[80vh] overflow-y-auto"
className="text-xs w-72 p-0"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
@@ -1099,7 +1099,7 @@ export function UsageIndicator() {
<PopoverContent
side="bottom"
align="end"
className="text-xs w-72 p-0 max-h-[80vh] overflow-y-auto"
className="text-xs w-72 p-0"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
@@ -1317,7 +1317,7 @@ export function UsageIndicator() {
<PopoverContent
side="bottom"
align="end"
className="text-xs w-72 p-0 max-h-[80vh] overflow-y-auto"
className="text-xs w-72 p-0"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
@@ -15,39 +15,35 @@ interface AccountsStepProps {
* Replaces the old AuthChoiceStep + OAuthStep two-step flow with a single
* step that reuses the ProviderAccountsList from settings. Users can add
* accounts from any supported provider (Anthropic, OpenAI, Google, etc.).
*
* Layout: The header and action buttons are pinned (always visible), while
* the provider list scrolls independently. This prevents the "Continue"
* button from being hidden below the fold on smaller screens.
*/
export function AccountsStep({ onNext, onBack, onSkip }: AccountsStepProps) {
const { t } = useTranslation('onboarding');
return (
<div className="flex h-full flex-col items-center px-8 py-6">
<div className="w-full max-w-2xl flex flex-col min-h-0 h-full">
{/* Header — pinned at top */}
<div className="text-center mb-6 flex-shrink-0">
<div className="flex justify-center mb-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<Users className="h-6 w-6 text-primary" />
<div className="w-full max-w-2xl">
{/* Header */}
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<Users className="h-8 w-8 text-primary" />
</div>
</div>
<h1 className="text-2xl font-bold text-foreground tracking-tight">
<h1 className="text-3xl font-bold text-foreground tracking-tight">
{t('accounts.title')}
</h1>
<p className="mt-2 text-muted-foreground text-base">
<p className="mt-3 text-muted-foreground text-lg">
{t('accounts.description')}
</p>
</div>
{/* Provider accounts list — scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto rounded-lg border border-border bg-card/50 p-4">
{/* Provider accounts list - reused from settings */}
<div className="rounded-lg border border-border bg-card/50 p-4">
<ProviderAccountsList />
</div>
{/* Action Buttons — pinned at bottom, always visible */}
<div className="flex justify-between items-center mt-4 pt-4 border-t border-border flex-shrink-0">
{/* Action Buttons */}
<div className="flex justify-between items-center mt-10 pt-6 border-t border-border">
<Button
variant="ghost"
onClick={onBack}
@@ -110,6 +110,20 @@ export function AddAccountDialog({
}
}, [open, editAccount, provider, billingModelOverride]);
// Parse DUPLICATE_EMAIL error from backend and show user-friendly toast
const handleDuplicateEmailError = useCallback((error: string): boolean => {
if (error.startsWith('DUPLICATE_EMAIL:')) {
const existingName = error.slice('DUPLICATE_EMAIL:'.length);
toast({
variant: 'destructive',
title: t('providers.dialog.toast.error'),
description: t('providers.dialog.toast.duplicateEmail', { existingName }),
});
return true;
}
return false;
}, [toast, t]);
const isOAuthOnly = (provider === 'anthropic' || provider === 'openai') && authType === 'oauth';
const isCodexOAuth = provider === 'openai' && authType === 'oauth';
@@ -190,10 +204,16 @@ export function AddAccountDialog({
: t('providers.dialog.toast.added'),
description: name.trim(),
});
} else if (result.error && !handleDuplicateEmailError(result.error)) {
toast({
variant: 'destructive',
title: t('providers.dialog.toast.error'),
description: result.error,
});
}
};
autoSave();
}, [oauthStatus, isCodexOAuth, accountSaved, name, provider, oauthProfileId, isEditing, editAccount, oauthEmail, addProviderAccount, updateProviderAccount, toast, t, refreshUsageData]);
}, [oauthStatus, isCodexOAuth, accountSaved, name, provider, oauthProfileId, isEditing, editAccount, oauthEmail, addProviderAccount, updateProviderAccount, handleDuplicateEmailError, toast, t, refreshUsageData]);
const canSave = () => {
if (!name.trim()) return false;
@@ -264,8 +284,14 @@ export function AddAccountDialog({
description: name.trim(),
});
await refreshUsageData();
onOpenChange(false);
} else if (saveResult.error && !handleDuplicateEmailError(saveResult.error)) {
toast({
variant: 'destructive',
title: t('providers.dialog.toast.error'),
description: saveResult.error,
});
}
onOpenChange(false);
}, 800);
} else {
setOauthStatus('error');
@@ -318,7 +344,7 @@ export function AddAccountDialog({
setOauthStatus('error');
setOauthError(err instanceof Error ? err.message : 'Unexpected error');
}
}, [name, t, toast, isCodexOAuth, isEditing, editAccount, provider, addProviderAccount, updateProviderAccount, onOpenChange, refreshUsageData]);
}, [name, t, toast, isCodexOAuth, isEditing, editAccount, provider, addProviderAccount, updateProviderAccount, handleDuplicateEmailError, onOpenChange, refreshUsageData]);
const handleFallbackTerminal = useCallback(async () => {
if (!name.trim()) {
@@ -342,7 +368,7 @@ export function AddAccountDialog({
createdAt: new Date(),
});
if (!profileResult.success || !profileResult.data) {
toast({ variant: 'destructive', title: 'Failed to create profile' });
toast({ variant: 'destructive', title: t('providers.dialog.toast.createProfileFailed') });
return;
}
profileId = profileResult.data.id;
@@ -352,7 +378,7 @@ export function AddAccountDialog({
// Get terminal config for embedded AuthTerminal
const authResult = await window.electronAPI.authenticateClaudeProfile(profileId);
if (!authResult.success || !authResult.data) {
toast({ variant: 'destructive', title: authResult.error ?? 'Failed to prepare terminal' });
toast({ variant: 'destructive', title: authResult.error ?? t('providers.dialog.toast.authPrepareFailed') });
return;
}
@@ -362,7 +388,7 @@ export function AddAccountDialog({
} catch (err) {
toast({
variant: 'destructive',
title: err instanceof Error ? err.message : 'Unexpected error',
title: err instanceof Error ? err.message : t('providers.dialog.toast.unexpectedError'),
});
}
}, [name, oauthProfileId, t, toast]);
@@ -421,7 +447,7 @@ export function AddAccountDialog({
description: name.trim(),
});
onOpenChange(false);
} else {
} else if (result.error && !handleDuplicateEmailError(result.error)) {
toast({
variant: 'destructive',
title: t('providers.dialog.toast.error'),
@@ -104,7 +104,6 @@ export const terminalMock = {
onTerminalAuthCreated: () => () => {},
onTerminalClaudeBusy: () => () => {},
onTerminalClaudeExit: () => () => {},
onTerminalOnboardingComplete: () => () => {},
onTerminalPendingResume: () => () => {},
onTerminalProfileChanged: () => () => {},
onTerminalOAuthCodeNeeded: () => () => {},
-1
View File
@@ -109,7 +109,6 @@ export const IPC_CHANNELS = {
TERMINAL_OAUTH_CODE_SUBMIT: 'terminal:oauthCodeSubmit', // User submitted OAuth code to send to terminal
TERMINAL_CLAUDE_BUSY: 'terminal:claudeBusy', // Claude Code busy state (for visual indicator)
TERMINAL_CLAUDE_EXIT: 'terminal:claudeExit', // Claude Code exited (returned to shell)
TERMINAL_ONBOARDING_COMPLETE: 'terminal:onboardingComplete', // Claude onboarding complete (ready for input after login)
TERMINAL_PROFILE_CHANGED: 'terminal:profileChanged', // Profile changed, terminals need refresh (main -> renderer)
// Claude profile management (multi-account support)
@@ -71,6 +71,12 @@ export const ALL_AVAILABLE_MODELS: ModelOption[] = [
{ value: 'glm-4.7', label: 'GLM-4.7', provider: 'zai', description: 'Previous flagship', capabilities: { thinking: false, tools: true, vision: false, contextWindow: 128000 } },
{ value: 'glm-4.6v', label: 'GLM-4.6V', provider: 'zai', description: 'Multimodal', capabilities: { thinking: false, tools: true, vision: true, contextWindow: 128000 } },
{ value: 'glm-4.5-flash', label: 'GLM-4.5 Flash', provider: 'zai', description: 'Fast', capabilities: { thinking: false, tools: true, vision: false, contextWindow: 128000 } },
// Mascarade (LLM Orchestration Engine)
{ value: 'mascarade-router', label: 'Mascarade Router', provider: 'mascarade', description: 'Multi-LLM routing (best/cheapest/fastest)', capabilities: { thinking: false, tools: true, vision: false, contextWindow: 128000 } },
{ value: 'mascarade-writer', label: 'Mascarade Writer', provider: 'mascarade', description: 'Content generation agent', capabilities: { thinking: false, tools: true, vision: false, contextWindow: 128000 } },
{ value: 'mascarade-coder', label: 'Mascarade Coder', provider: 'mascarade', description: 'Code review & generation', capabilities: { thinking: false, tools: true, vision: false, contextWindow: 128000 } },
{ value: 'mascarade-analyst', label: 'Mascarade Analyst', provider: 'mascarade', description: 'Data analysis agent', capabilities: { thinking: false, tools: true, vision: false, contextWindow: 128000 } },
{ value: 'mascarade-planner', label: 'Mascarade Planner', provider: 'mascarade', description: 'Project decomposition', capabilities: { thinking: false, tools: true, vision: false, contextWindow: 128000 } },
];
// Maps model shorthand to actual Claude model IDs
@@ -322,6 +328,12 @@ export const PROVIDER_PRESET_DEFINITIONS: Partial<Record<BuiltinProvider, Record
balanced: { primaryModel: '', primaryThinking: 'low', phaseModels: { spec: '', planning: '', coding: '', qa: '' }, phaseThinking: { spec: 'low', planning: 'low', coding: 'low', qa: 'low' } },
quick: { primaryModel: '', primaryThinking: 'low', phaseModels: { spec: '', planning: '', coding: '', qa: '' }, phaseThinking: { spec: 'low', planning: 'low', coding: 'low', qa: 'low' } },
},
mascarade: {
auto: { primaryModel: 'mascarade-router', primaryThinking: 'low', phaseModels: { spec: 'mascarade-router', planning: 'mascarade-router', coding: 'mascarade-router', qa: 'mascarade-router' }, phaseThinking: { spec: 'low', planning: 'low', coding: 'low', qa: 'low' } },
complex: { primaryModel: 'mascarade-router', primaryThinking: 'low', phaseModels: { spec: 'mascarade-router', planning: 'mascarade-router', coding: 'mascarade-router', qa: 'mascarade-router' }, phaseThinking: { spec: 'low', planning: 'low', coding: 'low', qa: 'low' } },
balanced: { primaryModel: 'mascarade-router', primaryThinking: 'low', phaseModels: { spec: 'mascarade-router', planning: 'mascarade-router', coding: 'mascarade-router', qa: 'mascarade-router' }, phaseThinking: { spec: 'low', planning: 'low', coding: 'low', qa: 'low' } },
quick: { primaryModel: 'mascarade-router', primaryThinking: 'low', phaseModels: { spec: 'mascarade-router', planning: 'mascarade-router', coding: 'mascarade-router', qa: 'mascarade-router' }, phaseThinking: { spec: 'low', planning: 'low', coding: 'low', qa: 'low' } },
},
};
/**
@@ -73,4 +73,11 @@ export const PROVIDER_REGISTRY: ProviderInfo[] = [
authMethods: ['api-key'], envVars: [],
configFields: ['baseUrl'],
},
{
id: 'mascarade', name: 'Mascarade', description: 'LLM Orchestration Engine — multi-provider routing with 19+ agents',
category: 'local',
authMethods: ['api-key'], envVars: ['MASCARADE_API_KEY'],
configFields: ['baseUrl'],
website: 'https://mascarade.saillant.cc',
},
];
@@ -674,11 +674,9 @@
"authFailed": "Authentication failed",
"connecting": "Connecting...",
"authenticate": "Authenticate: {{profileName}}",
"completeSetup": "Complete setup: {{profileName}}",
"authenticatedAs": "Authenticated as {{email}}",
"authenticated": "Authenticated!",
"authError": "Authentication Error",
"onboardingMessage": "Token received! Complete the Claude Code setup below - this window will close automatically when done.",
"successMessage": "Authentication successful! Closing..."
},
"profileCreated": {
@@ -810,7 +810,11 @@
"toast": {
"added": "Account added",
"updated": "Account updated",
"error": "Failed to save account"
"error": "Failed to save account",
"duplicateEmail": "This email is already registered as \"{{existingName}}\"",
"createProfileFailed": "Failed to create profile",
"authPrepareFailed": "Failed to prepare terminal",
"unexpectedError": "Unexpected error"
}
},
"toast": {
@@ -674,11 +674,9 @@
"authFailed": "Échec de l'authentification",
"connecting": "Connexion...",
"authenticate": "Authentifier : {{profileName}}",
"completeSetup": "Terminer la configuration : {{profileName}}",
"authenticatedAs": "Authentifié en tant que {{email}}",
"authenticated": "Authentifié !",
"authError": "Erreur d'authentification",
"onboardingMessage": "Jeton reçu ! Terminez la configuration de Claude Code ci-dessous - cette fenêtre se fermera automatiquement une fois terminé.",
"successMessage": "Authentification réussie ! Fermeture..."
},
"profileCreated": {
@@ -810,7 +810,11 @@
"toast": {
"added": "Compte ajouté",
"updated": "Compte mis à jour",
"error": "Échec de l'enregistrement du compte"
"error": "Échec de l'enregistrement du compte",
"duplicateEmail": "Cet e-mail est déjà enregistré sous \"{{existingName}}\"",
"createProfileFailed": "Échec de la création du profil",
"authPrepareFailed": "Échec de la préparation du terminal",
"unexpectedError": "Erreur inattendue"
}
},
"toast": {
-6
View File
@@ -298,12 +298,6 @@ export interface ElectronAPI {
onTerminalClaudeBusy: (callback: (id: string, isBusy: boolean) => void) => () => void;
/** Listen for Claude exit (user closed Claude within terminal, returned to shell) */
onTerminalClaudeExit: (callback: (id: string) => void) => () => void;
/** Listen for onboarding complete (Claude shows ready state after login/onboarding) */
onTerminalOnboardingComplete: (callback: (info: {
terminalId: string;
profileId?: string;
detectedAt: string;
}) => void) => () => void;
/** Listen for pending Claude resume notifications (for deferred resume on tab activation) */
onTerminalPendingResume: (callback: (id: string, sessionId?: string) => void) => () => void;
/** Listen for profile change events - terminals need to be recreated with new profile env vars */
@@ -7,7 +7,7 @@ export type CredentialSource = 'oauth' | 'api-key' | 'env' | 'keychain';
export type BuiltinProvider =
| 'anthropic' | 'openai' | 'google' | 'amazon-bedrock' | 'azure'
| 'mistral' | 'groq' | 'xai' | 'openrouter' | 'zai'
| 'ollama' | 'openai-compatible';
| 'ollama' | 'openai-compatible' | 'mascarade';
export type BillingModel = 'subscription' | 'pay-per-use';
+1123 -949
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -45,6 +45,6 @@
"lucide-react": "^0.562.0"
},
"overrides": {
"@electron/rebuild": "4.0.2"
"@electron/rebuild": "4.0.3"
}
}