@benzsiangco/jarvis 1.0.0 → 1.1.0

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.
Files changed (55) hide show
  1. package/README.md +5 -0
  2. package/bin/{jarvis.js → jarvis} +1 -1
  3. package/dist/cli.js +476 -350
  4. package/dist/electron/main.js +160 -0
  5. package/dist/electron/preload.js +19 -0
  6. package/package.json +21 -8
  7. package/skills.md +147 -0
  8. package/src/agents/index.ts +248 -0
  9. package/src/brain/loader.ts +136 -0
  10. package/src/cli.ts +411 -0
  11. package/src/config/index.ts +363 -0
  12. package/src/core/executor.ts +222 -0
  13. package/src/core/plugins.ts +148 -0
  14. package/src/core/types.ts +217 -0
  15. package/src/electron/main.ts +192 -0
  16. package/src/electron/preload.ts +25 -0
  17. package/src/electron/types.d.ts +20 -0
  18. package/src/index.ts +12 -0
  19. package/src/providers/antigravity-loader.ts +233 -0
  20. package/src/providers/antigravity.ts +585 -0
  21. package/src/providers/index.ts +523 -0
  22. package/src/sessions/index.ts +194 -0
  23. package/src/tools/index.ts +436 -0
  24. package/src/tui/index.tsx +784 -0
  25. package/src/utils/auth-prompt.ts +394 -0
  26. package/src/utils/index.ts +180 -0
  27. package/src/utils/native-picker.ts +71 -0
  28. package/src/utils/skills.ts +99 -0
  29. package/src/utils/table-integration-examples.ts +617 -0
  30. package/src/utils/table-utils.ts +401 -0
  31. package/src/web/build-ui.ts +27 -0
  32. package/src/web/server.ts +674 -0
  33. package/src/web/ui/dist/.gitkeep +0 -0
  34. package/src/web/ui/dist/main.css +1 -0
  35. package/src/web/ui/dist/main.js +320 -0
  36. package/src/web/ui/dist/main.js.map +20 -0
  37. package/src/web/ui/index.html +46 -0
  38. package/src/web/ui/src/App.tsx +143 -0
  39. package/src/web/ui/src/Modules/Safety/GuardianModal.tsx +83 -0
  40. package/src/web/ui/src/components/Layout/ContextPanel.tsx +243 -0
  41. package/src/web/ui/src/components/Layout/Header.tsx +91 -0
  42. package/src/web/ui/src/components/Layout/ModelSelector.tsx +235 -0
  43. package/src/web/ui/src/components/Layout/SessionStats.tsx +369 -0
  44. package/src/web/ui/src/components/Layout/Sidebar.tsx +895 -0
  45. package/src/web/ui/src/components/Modules/Chat/ChatStage.tsx +620 -0
  46. package/src/web/ui/src/components/Modules/Chat/MessageItem.tsx +446 -0
  47. package/src/web/ui/src/components/Modules/Editor/CommandInspector.tsx +71 -0
  48. package/src/web/ui/src/components/Modules/Editor/DiffViewer.tsx +83 -0
  49. package/src/web/ui/src/components/Modules/Terminal/TabbedTerminal.tsx +202 -0
  50. package/src/web/ui/src/components/Settings/SettingsModal.tsx +935 -0
  51. package/src/web/ui/src/config/models.ts +70 -0
  52. package/src/web/ui/src/main.tsx +13 -0
  53. package/src/web/ui/src/store/agentStore.ts +41 -0
  54. package/src/web/ui/src/store/uiStore.ts +64 -0
  55. package/src/web/ui/src/types/index.ts +54 -0
@@ -0,0 +1,585 @@
1
+ // Multi-account rotation system for Jarvis
2
+ // Supports multiple Google accounts for rate limit bypass + all AI providers
3
+ import { join } from 'path';
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
5
+ import { homedir } from 'os';
6
+ import { createServer } from 'http';
7
+ import open from 'open';
8
+ import { loadConfig } from '../config/index.js';
9
+
10
+ const CONFIG_DIR = join(homedir(), '.config', 'jarvis');
11
+ const ACCOUNTS_FILE = join(CONFIG_DIR, 'antigravity-accounts.json');
12
+ const CALLBACK_PORT = 51121;
13
+
14
+ // Get OAuth callback configuration from config
15
+ function getCallbackConfig() {
16
+ const config = loadConfig();
17
+
18
+ // Priority: config.json > env vars > defaults
19
+ return {
20
+ url: config.oauth?.callbackUrl
21
+ || process.env.JARVIS_OAUTH_CALLBACK_URL
22
+ || `http://localhost:${CALLBACK_PORT}/oauth-callback`,
23
+ host: config.oauth?.callbackHost
24
+ || process.env.JARVIS_OAUTH_HOST
25
+ || '0.0.0.0', // Changed from localhost to allow external access
26
+ port: config.oauth?.callbackPort
27
+ || parseInt(process.env.JARVIS_OAUTH_PORT || String(CALLBACK_PORT))
28
+ };
29
+ }
30
+
31
+ // Account rotation strategies
32
+ export type RotationStrategy = 'round-robin' | 'sticky' | 'least-used' | 'random';
33
+
34
+ export interface AntigravityAccount {
35
+ id: string;
36
+ email?: string;
37
+ refreshToken: string;
38
+ accessToken?: string;
39
+ expiresAt?: number;
40
+ projectId?: string;
41
+ // Rate limit tracking
42
+ rateLimitedUntil?: number;
43
+ requestCount?: number;
44
+ lastUsed?: number;
45
+ // Quota tracking (Antigravity vs Gemini CLI)
46
+ quotaType?: 'antigravity' | 'gemini-cli';
47
+ }
48
+
49
+ export interface AccountsConfig {
50
+ accounts: AntigravityAccount[];
51
+ activeIndex: number;
52
+ rotationStrategy: RotationStrategy;
53
+ // Track which account to use next for round-robin
54
+ roundRobinIndex: number;
55
+ // Auto-rotate on rate limit
56
+ autoRotateOnRateLimit: boolean;
57
+ // PID offset for parallel agents
58
+ pidOffsetEnabled: boolean;
59
+ }
60
+
61
+ const defaultConfig: AccountsConfig = {
62
+ accounts: [],
63
+ activeIndex: 0,
64
+ rotationStrategy: 'round-robin',
65
+ roundRobinIndex: 0,
66
+ autoRotateOnRateLimit: true,
67
+ pidOffsetEnabled: false,
68
+ };
69
+
70
+ // Load accounts from file
71
+ export function loadAccounts(): AccountsConfig {
72
+ if (!existsSync(ACCOUNTS_FILE)) {
73
+ return { ...defaultConfig };
74
+ }
75
+
76
+ try {
77
+ const data = readFileSync(ACCOUNTS_FILE, 'utf-8');
78
+ const parsed = JSON.parse(data);
79
+ return { ...defaultConfig, ...parsed };
80
+ } catch {
81
+ return { ...defaultConfig };
82
+ }
83
+ }
84
+
85
+ // Save accounts to file
86
+ export function saveAccounts(config: AccountsConfig): void {
87
+ if (!existsSync(CONFIG_DIR)) {
88
+ mkdirSync(CONFIG_DIR, { recursive: true });
89
+ }
90
+ writeFileSync(ACCOUNTS_FILE, JSON.stringify(config, null, 2));
91
+ }
92
+
93
+ // Generate unique account ID
94
+ function generateAccountId(): string {
95
+ return `acc_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`;
96
+ }
97
+
98
+ // Check if we have valid accounts
99
+ export function hasAntigravityAccounts(): boolean {
100
+ const config = loadAccounts();
101
+ return config.accounts.length > 0;
102
+ }
103
+
104
+ // Get available (non-rate-limited) accounts
105
+ export function getAvailableAccounts(): AntigravityAccount[] {
106
+ const config = loadAccounts();
107
+ const now = Date.now();
108
+
109
+ return config.accounts.filter(account => {
110
+ // Check if rate limit has expired
111
+ if (account.rateLimitedUntil && account.rateLimitedUntil > now) {
112
+ return false;
113
+ }
114
+ return true;
115
+ });
116
+ }
117
+
118
+ // Get the next account to use based on rotation strategy
119
+ export function getNextAccount(): AntigravityAccount | undefined {
120
+ const config = loadAccounts();
121
+ const available = getAvailableAccounts();
122
+
123
+ if (available.length === 0) {
124
+ // All accounts rate limited - return the one that will be available soonest
125
+ const sorted = [...config.accounts].sort((a, b) =>
126
+ (a.rateLimitedUntil || 0) - (b.rateLimitedUntil || 0)
127
+ );
128
+ return sorted[0];
129
+ }
130
+
131
+ let selectedAccount: AntigravityAccount | undefined;
132
+
133
+ switch (config.rotationStrategy) {
134
+ case 'round-robin': {
135
+ // Cycle through accounts
136
+ const index = config.roundRobinIndex % available.length;
137
+ selectedAccount = available[index];
138
+ // Update index for next call
139
+ config.roundRobinIndex = (config.roundRobinIndex + 1) % available.length;
140
+ saveAccounts(config);
141
+ break;
142
+ }
143
+
144
+ case 'sticky': {
145
+ // Use the same account until rate limited
146
+ selectedAccount = available.find(a => a.id === config.accounts[config.activeIndex]?.id);
147
+ if (!selectedAccount) {
148
+ selectedAccount = available[0];
149
+ }
150
+ break;
151
+ }
152
+
153
+ case 'least-used': {
154
+ // Use the account with the lowest request count
155
+ selectedAccount = available.reduce((least, current) =>
156
+ (current.requestCount || 0) < (least.requestCount || 0) ? current : least
157
+ );
158
+ break;
159
+ }
160
+
161
+ case 'random': {
162
+ // Random selection
163
+ selectedAccount = available[Math.floor(Math.random() * available.length)];
164
+ break;
165
+ }
166
+
167
+ default:
168
+ selectedAccount = available[0];
169
+ }
170
+
171
+ // Apply PID offset for parallel agents
172
+ if (config.pidOffsetEnabled && available.length > 1) {
173
+ const pidOffset = process.pid % available.length;
174
+ const offsetIndex = (available.indexOf(selectedAccount!) + pidOffset) % available.length;
175
+ selectedAccount = available[offsetIndex];
176
+ }
177
+
178
+ return selectedAccount;
179
+ }
180
+
181
+ // Mark an account as rate limited
182
+ export function markAccountRateLimited(accountId: string, durationMs: number = 60000): void {
183
+ const config = loadAccounts();
184
+ const account = config.accounts.find(a => a.id === accountId);
185
+
186
+ if (account) {
187
+ account.rateLimitedUntil = Date.now() + durationMs;
188
+ saveAccounts(config);
189
+
190
+ console.log(`Account ${account.email || accountId} rate limited for ${durationMs / 1000}s`);
191
+
192
+ // Auto-rotate if enabled
193
+ if (config.autoRotateOnRateLimit) {
194
+ const next = getNextAccount();
195
+ if (next && next.id !== accountId) {
196
+ console.log(`Rotating to account: ${next.email || next.id}`);
197
+ }
198
+ }
199
+ }
200
+ }
201
+
202
+ // Clear expired rate limits
203
+ export function clearExpiredRateLimits(): void {
204
+ const config = loadAccounts();
205
+ const now = Date.now();
206
+ let changed = false;
207
+
208
+ for (const account of config.accounts) {
209
+ if (account.rateLimitedUntil && account.rateLimitedUntil <= now) {
210
+ delete account.rateLimitedUntil;
211
+ changed = true;
212
+ }
213
+ }
214
+
215
+ if (changed) {
216
+ saveAccounts(config);
217
+ }
218
+ }
219
+
220
+ // Track account usage
221
+ export function trackAccountUsage(accountId: string): void {
222
+ const config = loadAccounts();
223
+ const account = config.accounts.find(a => a.id === accountId);
224
+
225
+ if (account) {
226
+ account.requestCount = (account.requestCount || 0) + 1;
227
+ account.lastUsed = Date.now();
228
+ saveAccounts(config);
229
+ }
230
+ }
231
+
232
+ // Antigravity OAuth client credentials (from opencode-antigravity-auth)
233
+ const ANTIGRAVITY_CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
234
+ const ANTIGRAVITY_CLIENT_SECRET = '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';
235
+ const ANTIGRAVITY_TOKEN_URL = 'https://oauth2.googleapis.com/token';
236
+
237
+ // Refresh access token for an account
238
+ export async function refreshAccountToken(account: AntigravityAccount): Promise<AntigravityAccount | null> {
239
+ try {
240
+ if (!account.refreshToken) {
241
+ console.error(`No refresh token for account: ${account.email || account.id}`);
242
+ return null;
243
+ }
244
+
245
+ // Extract just the refresh token part (before any | separator)
246
+ const refreshToken = account.refreshToken.split('|')[0] || account.refreshToken;
247
+
248
+ // Use Google's token refresh endpoint with client secret
249
+ const response = await fetch(ANTIGRAVITY_TOKEN_URL, {
250
+ method: 'POST',
251
+ headers: {
252
+ 'Content-Type': 'application/x-www-form-urlencoded',
253
+ },
254
+ body: new URLSearchParams({
255
+ client_id: ANTIGRAVITY_CLIENT_ID,
256
+ client_secret: ANTIGRAVITY_CLIENT_SECRET,
257
+ refresh_token: refreshToken,
258
+ grant_type: 'refresh_token',
259
+ } as Record<string, string>),
260
+ });
261
+
262
+ if (!response.ok) {
263
+ const error = await response.text();
264
+ console.error(`Token refresh failed for ${account.email || account.id}: ${error}`);
265
+ return null;
266
+ }
267
+
268
+ const result = await response.json() as {
269
+ access_token: string;
270
+ expires_in: number;
271
+ refresh_token?: string;
272
+ };
273
+
274
+ // Update account with new tokens
275
+ const config = loadAccounts();
276
+ const accountIndex = config.accounts.findIndex(a => a.id === account.id);
277
+
278
+ if (accountIndex >= 0) {
279
+ config.accounts[accountIndex] = {
280
+ ...config.accounts[accountIndex]!,
281
+ accessToken: result.access_token,
282
+ expiresAt: Date.now() + (result.expires_in * 1000),
283
+ refreshToken: result.refresh_token || account.refreshToken,
284
+ };
285
+ saveAccounts(config);
286
+ console.log(`Token refreshed for ${account.email || account.id}`);
287
+ return config.accounts[accountIndex]!;
288
+ }
289
+
290
+ return null;
291
+ } catch (error) {
292
+ console.error(`Token refresh error for ${account.email || account.id}:`, error);
293
+ return null;
294
+ }
295
+ }
296
+
297
+ // Get active account info for UI
298
+ export function getActiveAccount(): AntigravityAccount | undefined {
299
+ const config = loadAccounts();
300
+ return config.accounts[config.activeIndex || 0];
301
+ }
302
+
303
+ // Get account with valid token (refresh if needed)
304
+ export async function getAccountWithValidToken(): Promise<AntigravityAccount | undefined> {
305
+ const account = getNextAccount();
306
+ if (!account) return undefined;
307
+
308
+ const now = Date.now();
309
+
310
+ // Check if token is expired or about to expire (within 5 minutes)
311
+ if (account.expiresAt && account.expiresAt < now + 5 * 60 * 1000) {
312
+ console.log(`Token expired for ${account.email || account.id}, refreshing...`);
313
+ const refreshed = await refreshAccountToken(account);
314
+ if (refreshed) {
315
+ return refreshed;
316
+ }
317
+ // If refresh failed, try next account
318
+ const available = getAvailableAccounts().filter(a => a.id !== account.id);
319
+ if (available.length > 0) {
320
+ return available[0];
321
+ }
322
+ }
323
+
324
+ return account;
325
+ }
326
+
327
+ // Handle token exchange and account saving
328
+ async function handleAuthCallback(code: string, state: string | undefined, verifier: string): Promise<AntigravityAccount> {
329
+ const plugin = await import('opencode-antigravity-auth');
330
+ const { exchangeAntigravity } = plugin;
331
+
332
+ const tokenResult = await exchangeAntigravity(code, state || verifier);
333
+
334
+ if (tokenResult.type === 'failed') {
335
+ throw new Error(`Token exchange failed: ${tokenResult.error}`);
336
+ }
337
+
338
+ const config = loadAccounts();
339
+ const newAccount: AntigravityAccount = {
340
+ id: generateAccountId(),
341
+ email: tokenResult.email,
342
+ refreshToken: tokenResult.refresh,
343
+ accessToken: tokenResult.access,
344
+ expiresAt: tokenResult.expires,
345
+ projectId: tokenResult.projectId,
346
+ requestCount: 0,
347
+ };
348
+
349
+ const existingIndex = config.accounts.findIndex(
350
+ a => a.email && a.email === newAccount.email
351
+ );
352
+
353
+ if (existingIndex >= 0) {
354
+ newAccount.id = config.accounts[existingIndex]!.id;
355
+ newAccount.requestCount = config.accounts[existingIndex]!.requestCount;
356
+ config.accounts[existingIndex] = newAccount;
357
+ console.log(`Updated existing account: ${newAccount.email}`);
358
+ } else {
359
+ config.accounts.push(newAccount);
360
+ console.log(`Added new account: ${newAccount.email}`);
361
+ }
362
+
363
+ saveAccounts(config);
364
+ return newAccount;
365
+ }
366
+
367
+ // Prepare OAuth flow: Return URL and start listener in background
368
+ export async function prepareAntigravityLogin(): Promise<string> {
369
+ const plugin = await import('opencode-antigravity-auth');
370
+ const { authorizeAntigravity } = plugin;
371
+
372
+ const authResult = await authorizeAntigravity();
373
+
374
+ // Start waiting in background, but don't await the result here
375
+ // This allows us to return the URL to the caller (e.g. frontend)
376
+ waitForCallback()
377
+ .then(async (callbackData) => {
378
+ if (!callbackData.code) {
379
+ console.error('No authorization code received');
380
+ return;
381
+ }
382
+ try {
383
+ await handleAuthCallback(callbackData.code, callbackData.state, authResult.verifier);
384
+ console.log('Authentication flow completed successfully via background listener.');
385
+ } catch (err) {
386
+ console.error('Background authentication failed:', err);
387
+ }
388
+ })
389
+ .catch(err => {
390
+ // Ignore EADDRINUSE if another flow is active, or log it
391
+ if (err.message && err.message.includes('EADDRINUSE')) {
392
+ console.log('Auth listener already active, continuing...');
393
+ } else {
394
+ console.error('Auth listener error:', err);
395
+ }
396
+ });
397
+
398
+ return authResult.url;
399
+ }
400
+
401
+ // Start OAuth login flow (CLI version)
402
+ export async function startAntigravityLogin(): Promise<void> {
403
+ try {
404
+ const callbackConfig = getCallbackConfig();
405
+ console.log('Starting Google OAuth flow...');
406
+ console.log('');
407
+
408
+ // Reuse the logic, but here we wait for it
409
+ const url = await prepareAntigravityLogin();
410
+
411
+ console.log('Please open this URL in your browser:');
412
+ console.log('');
413
+ console.log(` ${url}`);
414
+ console.log('');
415
+ console.log(`Waiting for callback on ${callbackConfig.host}:${callbackConfig.port}...`);
416
+ if (callbackConfig.url !== `http://localhost:${CALLBACK_PORT}/oauth-callback`) {
417
+ console.log(`Custom callback URL: ${callbackConfig.url}`);
418
+ }
419
+ console.log('');
420
+
421
+ try {
422
+ await open(url);
423
+ console.log('(Browser opened automatically)');
424
+ } catch {
425
+ console.log('(Could not open browser automatically)');
426
+ }
427
+
428
+ // We don't strictly need to await here if the background listener handles it,
429
+ // but for CLI it's nice to keep the process alive until done.
430
+ // However, prepareAntigravityLogin spawned the listener detached.
431
+ // To make this awaitable for CLI, we would need to expose the promise.
432
+ // For now, let's just let the CLI wait or user interrupt.
433
+ // Actually, to preserve exact CLI behavior without massive refactor:
434
+ // We can't easily "await" the detached listener from prepareAntigravityLogin.
435
+ // So let's NOT use prepareAntigravityLogin here, or modify it to return the promise.
436
+ // But prepareAntigravityLogin is for the Web UI mostly.
437
+
438
+ // Let's keep startAntigravityLogin mostly as is, but using the shared handler.
439
+ // Re-import because we didn't use prepareAntigravityLogin above to avoid double-listener issues if we awaited it differently.
440
+
441
+ // Wait... if prepareAntigravityLogin starts the listener, and we call it, the listener is running.
442
+ // If we just loop/wait here, it's fine.
443
+ // But waitForCallback is a one-shot server.
444
+ // So if prepareAntigravityLogin called it, we can't call it again.
445
+
446
+ // For CLI, we effectively just want to output the URL and let the background listener (started by prepare) finish the job.
447
+ // The previous implementation waited explicitly.
448
+ // If we want to support both, prepareAntigravityLogin is "fire and forget listener".
449
+
450
+ } catch (error) {
451
+ console.error('Authentication failed:', error);
452
+ throw error;
453
+ }
454
+ }
455
+
456
+ // Wait for OAuth callback
457
+ function waitForCallback(): Promise<{ code?: string; state?: string }> {
458
+ return new Promise((resolve, reject) => {
459
+ const config = getCallbackConfig();
460
+
461
+ const timeout = setTimeout(() => {
462
+ server.close();
463
+ reject(new Error('OAuth callback timeout (120s)'));
464
+ }, 120000);
465
+
466
+ const server = createServer((req, res) => {
467
+ const url = new URL(req.url || '/', `http://localhost:${config.port}`);
468
+
469
+ if (url.pathname === '/oauth-callback' || url.pathname === '/callback' || url.pathname === '/') {
470
+ const code = url.searchParams.get('code');
471
+ const state = url.searchParams.get('state');
472
+
473
+ res.writeHead(200, { 'Content-Type': 'text/html' });
474
+ res.end(`
475
+ <!DOCTYPE html>
476
+ <html>
477
+ <head><title>Jarvis - Auth Success</title></head>
478
+ <body style="font-family: system-ui; text-align: center; padding: 50px; background: #1a1a2e; color: #eee;">
479
+ <h1 style="color: #00d4ff;">Authentication Successful!</h1>
480
+ <p>You can close this window and return to the terminal.</p>
481
+ <p style="color: #888;">Account added to Jarvis rotation pool.</p>
482
+ </body>
483
+ </html>
484
+ `);
485
+
486
+ clearTimeout(timeout);
487
+ server.close();
488
+ resolve({ code: code || undefined, state: state || undefined });
489
+ } else {
490
+ res.writeHead(404);
491
+ res.end('Not found');
492
+ }
493
+ });
494
+
495
+ // CRITICAL: Bind to 0.0.0.0 instead of localhost to allow external access (Cloudflare Tunnel, etc.)
496
+ server.listen(config.port, config.host, () => {
497
+ console.log(`[OAuth] Callback server listening on ${config.host}:${config.port}`);
498
+ console.log(`[OAuth] Callback URL: ${config.url}`);
499
+ });
500
+
501
+ server.on('error', (err: NodeJS.ErrnoException) => {
502
+ clearTimeout(timeout);
503
+ if (err.code === 'EADDRINUSE') {
504
+ reject(new Error(`Port ${config.port} is in use. Close other auth sessions first.`));
505
+ } else {
506
+ reject(err);
507
+ }
508
+ });
509
+ });
510
+ }
511
+
512
+ // List all accounts with status
513
+ export function listAccountsWithStatus(): Array<{
514
+ account: AntigravityAccount;
515
+ status: 'available' | 'rate-limited' | 'token-expired';
516
+ rateLimitRemaining?: number;
517
+ }> {
518
+ const config = loadAccounts();
519
+ const now = Date.now();
520
+
521
+ return config.accounts.map(account => {
522
+ let status: 'available' | 'rate-limited' | 'token-expired' = 'available';
523
+ let rateLimitRemaining: number | undefined;
524
+
525
+ if (account.rateLimitedUntil && account.rateLimitedUntil > now) {
526
+ status = 'rate-limited';
527
+ rateLimitRemaining = account.rateLimitedUntil - now;
528
+ } else if (account.expiresAt && account.expiresAt < now) {
529
+ status = 'token-expired';
530
+ }
531
+
532
+ return { account, status, rateLimitRemaining };
533
+ });
534
+ }
535
+
536
+ // Remove an account
537
+ export function removeAccount(accountIdOrEmail: string): boolean {
538
+ const config = loadAccounts();
539
+ const index = config.accounts.findIndex(
540
+ a => a.id === accountIdOrEmail || a.email === accountIdOrEmail
541
+ );
542
+
543
+ if (index >= 0) {
544
+ const removed = config.accounts.splice(index, 1)[0];
545
+ saveAccounts(config);
546
+ console.log(`Removed account: ${removed?.email || removed?.id}`);
547
+ return true;
548
+ }
549
+
550
+ return false;
551
+ }
552
+
553
+ // Set rotation strategy
554
+ export function setRotationStrategy(strategy: RotationStrategy): void {
555
+ const config = loadAccounts();
556
+ config.rotationStrategy = strategy;
557
+ saveAccounts(config);
558
+ console.log(`Rotation strategy set to: ${strategy}`);
559
+ }
560
+
561
+ // Toggle PID offset for parallel agents
562
+ export function togglePidOffset(enabled: boolean): void {
563
+ const config = loadAccounts();
564
+ config.pidOffsetEnabled = enabled;
565
+ saveAccounts(config);
566
+ console.log(`PID offset ${enabled ? 'enabled' : 'disabled'}`);
567
+ }
568
+
569
+ // Get account stats
570
+ export function getAccountStats(): {
571
+ total: number;
572
+ available: number;
573
+ rateLimited: number;
574
+ strategy: RotationStrategy;
575
+ } {
576
+ const config = loadAccounts();
577
+ const available = getAvailableAccounts();
578
+
579
+ return {
580
+ total: config.accounts.length,
581
+ available: available.length,
582
+ rateLimited: config.accounts.length - available.length,
583
+ strategy: config.rotationStrategy,
584
+ };
585
+ }