@pixelbyte-software/pixcode 1.33.9 → 1.33.11

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 (38) hide show
  1. package/dist/api-docs.html +373 -857
  2. package/dist/assets/{index-DpIcI9Q1.js → index-oLYHJ2X5.js} +154 -166
  3. package/dist/index.html +1 -1
  4. package/dist/openapi.yaml +1311 -0
  5. package/dist-server/server/gemini-cli.js +59 -0
  6. package/dist-server/server/gemini-cli.js.map +1 -1
  7. package/dist-server/server/index.js +6 -1
  8. package/dist-server/server/index.js.map +1 -1
  9. package/dist-server/server/middleware/auth.js +51 -9
  10. package/dist-server/server/middleware/auth.js.map +1 -1
  11. package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js +54 -15
  12. package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js.map +1 -1
  13. package/dist-server/server/modules/providers/list/qwen/qwen-sessions.provider.js +46 -0
  14. package/dist-server/server/modules/providers/list/qwen/qwen-sessions.provider.js.map +1 -1
  15. package/dist-server/server/modules/providers/provider.routes.js +32 -1
  16. package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
  17. package/dist-server/server/opencode-cli.js +41 -2
  18. package/dist-server/server/opencode-cli.js.map +1 -1
  19. package/dist-server/server/opencode-response-handler.js +36 -34
  20. package/dist-server/server/opencode-response-handler.js.map +1 -1
  21. package/dist-server/server/routes/agent.js +187 -56
  22. package/dist-server/server/routes/agent.js.map +1 -1
  23. package/dist-server/server/routes/projects.js +134 -8
  24. package/dist-server/server/routes/projects.js.map +1 -1
  25. package/dist-server/server/services/provider-credentials.js +42 -8
  26. package/dist-server/server/services/provider-credentials.js.map +1 -1
  27. package/package.json +1 -1
  28. package/server/gemini-cli.js +60 -0
  29. package/server/index.js +6 -1
  30. package/server/middleware/auth.js +50 -9
  31. package/server/modules/providers/list/opencode/opencode-sessions.provider.ts +60 -21
  32. package/server/modules/providers/list/qwen/qwen-sessions.provider.ts +47 -0
  33. package/server/modules/providers/provider.routes.ts +37 -4
  34. package/server/opencode-cli.js +41 -2
  35. package/server/opencode-response-handler.js +36 -29
  36. package/server/routes/agent.js +178 -58
  37. package/server/routes/projects.js +136 -8
  38. package/server/services/provider-credentials.js +42 -8
@@ -10,6 +10,8 @@ import { queryClaudeSDK } from '../claude-sdk.js';
10
10
  import { spawnCursor } from '../cursor-cli.js';
11
11
  import { queryCodex } from '../openai-codex.js';
12
12
  import { spawnGemini } from '../gemini-cli.js';
13
+ import { spawnQwen } from '../qwen-code-cli.js';
14
+ import { spawnOpencode } from '../opencode-cli.js';
13
15
  import { Octokit } from '@octokit/rest';
14
16
  import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
15
17
  import { IS_PLATFORM } from '../constants/config.js';
@@ -44,11 +46,19 @@ const validateExternalApiKey = (req, res, next) => {
44
46
  }
45
47
  }
46
48
 
47
- // Self-hosted mode: Validate API key from header or query parameter
48
- const apiKey = req.headers['x-api-key'] || req.query.apiKey;
49
+ // Self-hosted mode: validate API key from any of the supported transports.
50
+ // - Authorization: Bearer ck_... (added so /api/agent accepts the same
51
+ // auth shape as the rest of the API, per the auth-unify in this turn)
52
+ // - X-API-Key: ck_... (legacy, kept working)
53
+ // - ?apiKey=ck_... (EventSource workaround)
54
+ const authHeader = req.headers['authorization'];
55
+ const bearer = authHeader && authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : null;
56
+ const apiKey = (bearer && bearer.startsWith('ck_') ? bearer : null)
57
+ || req.headers['x-api-key']
58
+ || (typeof req.query.apiKey === 'string' ? req.query.apiKey : null);
49
59
 
50
60
  if (!apiKey) {
51
- return res.status(401).json({ error: 'API key required' });
61
+ return res.status(401).json({ error: 'API key required (Authorization: Bearer ck_..., X-API-Key, or ?apiKey=)' });
52
62
  }
53
63
 
54
64
  const user = apiKeysDb.validateApiKey(apiKey);
@@ -529,74 +539,129 @@ class ResponseCollector {
529
539
  }
530
540
 
531
541
  /**
532
- * Get filtered assistant messages only
542
+ * Get filtered assistant messages.
543
+ *
544
+ * Two message shapes are observed in the wild:
545
+ * 1. Legacy Claude-only: { type:'claude-response', data:{ type:'assistant', message:{...} } }
546
+ * 2. Unified normalized: { kind:'stream_delta'|'tool_use'|... , provider, content, ... }
547
+ * (every provider after the v1.30+ unified-message migration emits this)
548
+ *
549
+ * Pre-fix this method only matched (1), so qwen / gemini / opencode / codex
550
+ * runs all returned an empty array even when the provider streamed real
551
+ * text. Now it builds:
552
+ * - one synthetic assistant entry per chat turn from concatenated
553
+ * `stream_delta` content (boundary = `stream_end` or `complete`)
554
+ * - tool_use / tool_result entries pass through verbatim
533
555
  */
534
556
  getAssistantMessages() {
535
- const assistantMessages = [];
557
+ const out = [];
558
+ let textBuffer = '';
559
+
560
+ const flushText = () => {
561
+ if (!textBuffer) return;
562
+ out.push({
563
+ type: 'assistant',
564
+ message: {
565
+ role: 'assistant',
566
+ content: [{ type: 'text', text: textBuffer }],
567
+ },
568
+ });
569
+ textBuffer = '';
570
+ };
536
571
 
537
- for (const msg of this.messages) {
538
- // Skip initial status message
539
- if (msg && msg.type === 'status') {
572
+ for (const raw of this.messages) {
573
+ const data = typeof raw === 'string'
574
+ ? (() => { try { return JSON.parse(raw); } catch { return null; } })()
575
+ : raw;
576
+ if (!data) continue;
577
+ if (data.type === 'status') continue;
578
+
579
+ // Unified shape (every modern provider).
580
+ // - `stream_delta`: incremental text chunk (most providers)
581
+ // - `text`: full text part for one assistant turn (Claude SDK + history reads)
582
+ // - `thinking`: reasoning blocks; we coalesce as plain text so the API caller sees something
583
+ if ((data.kind === 'stream_delta' || data.kind === 'text' || data.kind === 'thinking')
584
+ && (typeof data.content === 'string' || Array.isArray(data.content))) {
585
+ const text = typeof data.content === 'string'
586
+ ? data.content
587
+ : data.content.map((part) => (typeof part === 'string' ? part : (part?.text || ''))).join('');
588
+ textBuffer += text;
589
+ continue;
590
+ }
591
+ if (data.kind === 'stream_end' || data.kind === 'complete') {
592
+ flushText();
593
+ continue;
594
+ }
595
+ if (data.kind === 'tool_use') {
596
+ flushText();
597
+ out.push({ type: 'tool_use', id: data.toolId, name: data.toolName, input: data.toolInput });
598
+ continue;
599
+ }
600
+ if (data.kind === 'tool_result') {
601
+ out.push({ type: 'tool_result', tool_use_id: data.toolId, content: data.content, is_error: data.isError });
602
+ continue;
603
+ }
604
+ if (data.kind === 'error' && typeof data.content === 'string') {
605
+ flushText();
606
+ out.push({ type: 'error', content: data.content });
540
607
  continue;
541
608
  }
542
609
 
543
- // Handle JSON strings
544
- if (typeof msg === 'string') {
545
- try {
546
- const parsed = JSON.parse(msg);
547
- // Only include claude-response messages with assistant type
548
- if (parsed.type === 'claude-response' && parsed.data && parsed.data.type === 'assistant') {
549
- assistantMessages.push(parsed.data);
550
- }
551
- } catch (e) {
552
- // Not JSON, skip
553
- }
610
+ // Legacy Claude shape — kept so old SDK builds still report cleanly.
611
+ if (data.type === 'claude-response' && data.data && data.data.type === 'assistant') {
612
+ flushText();
613
+ out.push(data.data);
554
614
  }
555
615
  }
556
-
557
- return assistantMessages;
616
+ flushText();
617
+ return out;
558
618
  }
559
619
 
560
620
  /**
561
- * Calculate total tokens from all messages
621
+ * Calculate total tokens from all messages.
622
+ *
623
+ * Two usage shapes observed:
624
+ * 1. Legacy Claude: { type:'claude-response', data:{ message:{ usage:{ input_tokens, output_tokens, cache_*_input_tokens } } } }
625
+ * 2. Unified `complete`/ { kind:'complete'|'stream_end', usage:{ input, output, cacheRead?, cacheCreation? }, cost? }
626
+ * `stream_end` events
562
627
  */
563
628
  getTotalTokens() {
564
- let totalInput = 0;
565
- let totalOutput = 0;
566
- let totalCacheRead = 0;
567
- let totalCacheCreation = 0;
568
-
569
- for (const msg of this.messages) {
570
- let data = msg;
571
-
572
- // Parse if string
573
- if (typeof msg === 'string') {
574
- try {
575
- data = JSON.parse(msg);
576
- } catch (e) {
577
- continue;
578
- }
629
+ let inputTokens = 0;
630
+ let outputTokens = 0;
631
+ let cacheReadTokens = 0;
632
+ let cacheCreationTokens = 0;
633
+
634
+ for (const raw of this.messages) {
635
+ const data = typeof raw === 'string'
636
+ ? (() => { try { return JSON.parse(raw); } catch { return null; } })()
637
+ : raw;
638
+ if (!data) continue;
639
+
640
+ // Unified shape
641
+ if (data.usage && typeof data.usage === 'object') {
642
+ inputTokens += data.usage.input || data.usage.inputTokens || data.usage.input_tokens || 0;
643
+ outputTokens += data.usage.output || data.usage.outputTokens || data.usage.output_tokens || 0;
644
+ cacheReadTokens += data.usage.cacheRead || data.usage.cache_read_input_tokens || 0;
645
+ cacheCreationTokens += data.usage.cacheCreation || data.usage.cache_creation_input_tokens || 0;
646
+ continue;
579
647
  }
580
648
 
581
- // Extract usage from claude-response messages
582
- if (data && data.type === 'claude-response' && data.data) {
583
- const msgData = data.data;
584
- if (msgData.message && msgData.message.usage) {
585
- const usage = msgData.message.usage;
586
- totalInput += usage.input_tokens || 0;
587
- totalOutput += usage.output_tokens || 0;
588
- totalCacheRead += usage.cache_read_input_tokens || 0;
589
- totalCacheCreation += usage.cache_creation_input_tokens || 0;
590
- }
649
+ // Legacy Claude
650
+ if (data.type === 'claude-response' && data.data && data.data.message && data.data.message.usage) {
651
+ const u = data.data.message.usage;
652
+ inputTokens += u.input_tokens || 0;
653
+ outputTokens += u.output_tokens || 0;
654
+ cacheReadTokens += u.cache_read_input_tokens || 0;
655
+ cacheCreationTokens += u.cache_creation_input_tokens || 0;
591
656
  }
592
657
  }
593
658
 
594
659
  return {
595
- inputTokens: totalInput,
596
- outputTokens: totalOutput,
597
- cacheReadTokens: totalCacheRead,
598
- cacheCreationTokens: totalCacheCreation,
599
- totalTokens: totalInput + totalOutput + totalCacheRead + totalCacheCreation
660
+ inputTokens,
661
+ outputTokens,
662
+ cacheReadTokens,
663
+ cacheCreationTokens,
664
+ totalTokens: inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens,
600
665
  };
601
666
  }
602
667
  }
@@ -859,8 +924,8 @@ router.post('/', validateExternalApiKey, async (req, res) => {
859
924
  return res.status(400).json({ error: 'message is required' });
860
925
  }
861
926
 
862
- if (!['claude', 'cursor', 'codex', 'gemini'].includes(provider)) {
863
- return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", or "gemini"' });
927
+ if (!['claude', 'cursor', 'codex', 'gemini', 'qwen', 'opencode'].includes(provider)) {
928
+ return res.status(400).json({ error: 'provider must be one of: claude, cursor, codex, gemini, qwen, opencode' });
864
929
  }
865
930
 
866
931
  // Validate GitHub branch/PR creation requirements
@@ -985,6 +1050,27 @@ router.post('/', validateExternalApiKey, async (req, res) => {
985
1050
  model: model,
986
1051
  skipPermissions: true // CLI mode bypasses permissions
987
1052
  }, writer);
1053
+ } else if (provider === 'qwen') {
1054
+ console.log('🐉 Starting Qwen Code CLI session');
1055
+
1056
+ await spawnQwen(message.trim(), {
1057
+ projectPath: finalProjectPath,
1058
+ cwd: finalProjectPath,
1059
+ sessionId: sessionId || null,
1060
+ model: model,
1061
+ skipPermissions: true,
1062
+ }, writer);
1063
+ } else if (provider === 'opencode') {
1064
+ console.log('🅾️ Starting OpenCode CLI session');
1065
+
1066
+ await spawnOpencode(message.trim(), {
1067
+ projectPath: finalProjectPath,
1068
+ cwd: finalProjectPath,
1069
+ sessionId: sessionId || null,
1070
+ model: model,
1071
+ permissionMode: 'bypassPermissions',
1072
+ toolsSettings: { allowPatterns: [], denyPatterns: [], skipPermissions: true },
1073
+ }, writer);
988
1074
  }
989
1075
 
990
1076
  // Handle GitHub branch and PR creation after successful agent completion
@@ -1177,13 +1263,30 @@ router.post('/', validateExternalApiKey, async (req, res) => {
1177
1263
  const assistantMessages = writer.getAssistantMessages();
1178
1264
  const tokenSummary = writer.getTotalTokens();
1179
1265
 
1266
+ // Promote provider-side errors (`writer.send({ kind:'error', ... })`)
1267
+ // to the response envelope. Without this, providers like Codex —
1268
+ // whose SDK swallows throws and only emits an error message — left
1269
+ // callers with `{ success:true, messages:[] }`, indistinguishable
1270
+ // from a quiet success. Now: any error event => success:false and
1271
+ // the human-readable text on `error`.
1272
+ const errorEntry = assistantMessages.find((m) => m.type === 'error');
1273
+ const hasAssistantText = assistantMessages.some(
1274
+ (m) => m.type === 'assistant' && m.message?.content?.some?.((p) => p.type === 'text' && p.text)
1275
+ );
1276
+ const succeeded = !errorEntry && (hasAssistantText || assistantMessages.some((m) => m.type === 'tool_use' || m.type === 'tool_result'));
1277
+
1180
1278
  const response = {
1181
- success: true,
1279
+ success: succeeded,
1182
1280
  sessionId: writer.getSessionId(),
1183
1281
  messages: assistantMessages,
1184
1282
  tokens: tokenSummary,
1185
1283
  projectPath: finalProjectPath
1186
1284
  };
1285
+ if (errorEntry) {
1286
+ response.error = errorEntry.content;
1287
+ } else if (!succeeded) {
1288
+ response.error = 'Provider returned no assistant text. Check backend log for details.';
1289
+ }
1187
1290
 
1188
1291
  // Add branch/PR info if created
1189
1292
  if (branchInfo) {
@@ -1193,7 +1296,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
1193
1296
  response.pullRequest = prInfo;
1194
1297
  }
1195
1298
 
1196
- res.json(response);
1299
+ res.status(succeeded ? 200 : 502).json(response);
1197
1300
  }
1198
1301
 
1199
1302
  // Clean up if requested
@@ -1234,9 +1337,26 @@ router.post('/', validateExternalApiKey, async (req, res) => {
1234
1337
  writer.end();
1235
1338
  }
1236
1339
  } else if (!res.headersSent) {
1237
- res.status(500).json({
1340
+ // Surface any provider-side stderr/error events the writer collected
1341
+ // BEFORE the throw — without this, callers only see the bland
1342
+ // "Gemini CLI exited with code 403" wrapper and lose the actual
1343
+ // "PERMISSION_DENIED, model not enabled for this account" detail
1344
+ // that the CLI printed to stderr.
1345
+ let collectedError = null;
1346
+ let collectedMessages = [];
1347
+ if (writer && typeof writer.getAssistantMessages === 'function') {
1348
+ try {
1349
+ collectedMessages = writer.getAssistantMessages();
1350
+ const errEntry = collectedMessages.find((m) => m.type === 'error');
1351
+ if (errEntry) collectedError = errEntry.content;
1352
+ } catch { /* ignore — fall back to error.message */ }
1353
+ }
1354
+ res.status(502).json({
1238
1355
  success: false,
1239
- error: error.message
1356
+ sessionId: writer && typeof writer.getSessionId === 'function' ? writer.getSessionId() : null,
1357
+ error: collectedError || error.message,
1358
+ wrapperError: collectedError ? error.message : undefined,
1359
+ messages: collectedMessages,
1240
1360
  });
1241
1361
  }
1242
1362
  }
@@ -18,7 +18,18 @@ export const WORKSPACES_BASE = path.resolve(
18
18
  process.env.WORKSPACES_BASE || path.join(WORKSPACES_ROOT, 'pixcode', 'projects')
19
19
  );
20
20
 
21
- // System-critical paths that should never be used as workspace directories
21
+ // System-critical paths that should never be used as workspace directories.
22
+ // `/root` is conditional — included only when the server is NOT running as
23
+ // root. On a typical VPS deployment (`sudo` install, root-owned daemon)
24
+ // `/root` IS the user's home directory, blocking projects under it locks
25
+ // users out of their own filesystem. The carve-out below was meant to
26
+ // handle this but only allowed paths under WORKSPACES_BASE — users with
27
+ // `/root/foo` from before Pixcode existed couldn't open them.
28
+ const RUNNING_AS_ROOT =
29
+ process.platform !== 'win32' &&
30
+ typeof process.getuid === 'function' &&
31
+ process.getuid() === 0;
32
+
22
33
  export const FORBIDDEN_PATHS = [
23
34
  // Unix
24
35
  '/',
@@ -31,7 +42,7 @@ export const FORBIDDEN_PATHS = [
31
42
  '/sys',
32
43
  '/var',
33
44
  '/boot',
34
- '/root',
45
+ ...(RUNNING_AS_ROOT ? [] : ['/root']),
35
46
  '/lib',
36
47
  '/lib64',
37
48
  '/opt',
@@ -413,15 +424,18 @@ router.post('/quick-start', async (req, res) => {
413
424
  */
414
425
  router.post('/create-workspace', async (req, res) => {
415
426
  try {
416
- const { workspaceType, path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.body;
427
+ const { workspaceType, path: workspacePath, githubUrl, githubTokenId, newGithubToken, subfolderName } = req.body;
417
428
 
418
429
  // Validate required fields
419
430
  if (!workspaceType || !workspacePath) {
420
431
  return res.status(400).json({ error: 'workspaceType and path are required' });
421
432
  }
422
433
 
423
- if (!['existing', 'new'].includes(workspaceType)) {
424
- return res.status(400).json({ error: 'workspaceType must be "existing" or "new"' });
434
+ // 'existing' = open the picked folder as-is
435
+ // 'new' = clone a github repo into the picked folder (legacy name kept for client compat)
436
+ // 'subfolder'= create a fresh subfolder INSIDE the picked folder and open that
437
+ if (!['existing', 'new', 'subfolder'].includes(workspaceType)) {
438
+ return res.status(400).json({ error: 'workspaceType must be "existing", "new", or "subfolder"' });
425
439
  }
426
440
 
427
441
  // Validate path safety before any operations
@@ -452,13 +466,127 @@ router.post('/create-workspace', async (req, res) => {
452
466
  throw error;
453
467
  }
454
468
 
455
- // Add the existing workspace to the project list
456
- const project = await addProjectManually(absolutePath);
469
+ // Add the existing workspace to the project list. If the user picks
470
+ // a folder Pixcode has already registered (very common when bouncing
471
+ // between sessions or re-opening the wizard on the same project),
472
+ // `addProjectManually` throws "Project already configured…" — that
473
+ // used to surface as a hard error in the UI even though the right
474
+ // outcome is "great, let's just open it." Treat that one specific
475
+ // throw as a soft re-open and return a 200 with `alreadyExisted: true`
476
+ // so the wizard can show "Opened existing workspace" instead of the
477
+ // raw error message.
478
+ let project;
479
+ let alreadyExisted = false;
480
+ try {
481
+ project = await addProjectManually(absolutePath);
482
+ } catch (error) {
483
+ const msg = error?.message || '';
484
+ if (!/already configured/i.test(msg)) throw error;
485
+ alreadyExisted = true;
486
+ project = {
487
+ name: absolutePath.replace(/[\\/:]/g, '-').replace(/\./g, '-'),
488
+ path: absolutePath,
489
+ fullPath: absolutePath,
490
+ displayName: path.basename(absolutePath),
491
+ isManuallyAdded: true,
492
+ sessions: [],
493
+ cursorSessions: [],
494
+ };
495
+ }
457
496
 
458
497
  return res.json({
459
498
  success: true,
460
499
  project,
461
- message: 'Existing workspace added successfully'
500
+ alreadyExisted,
501
+ message: alreadyExisted
502
+ ? 'Workspace was already registered — opening it'
503
+ : 'Existing workspace added successfully'
504
+ });
505
+ }
506
+
507
+ // Handle subfolder creation: user picked a parent dir, we mkdir
508
+ // <parent>/<subfolderName> and open that.
509
+ if (workspaceType === 'subfolder') {
510
+ const trimmedName = typeof subfolderName === 'string' ? subfolderName.trim() : '';
511
+ if (!trimmedName) {
512
+ return res.status(400).json({ error: 'subfolderName is required when workspaceType is "subfolder"' });
513
+ }
514
+ // Reject path-traversal / nested separators / reserved names. The
515
+ // wizard's UI will only ever send a flat folder name; anything else
516
+ // is either a bug or someone fishing.
517
+ if (/[\\/]/.test(trimmedName) || trimmedName === '.' || trimmedName === '..') {
518
+ return res.status(400).json({ error: 'subfolderName must be a single folder name (no path separators)' });
519
+ }
520
+
521
+ // Verify parent dir exists (we don't auto-create the picked parent —
522
+ // user already pointed at a real folder).
523
+ try {
524
+ const stats = await fs.stat(absolutePath);
525
+ if (!stats.isDirectory()) {
526
+ return res.status(400).json({ error: 'Parent path is not a directory' });
527
+ }
528
+ } catch (error) {
529
+ if (error.code === 'ENOENT') {
530
+ return res.status(404).json({ error: 'Parent directory does not exist' });
531
+ }
532
+ throw error;
533
+ }
534
+
535
+ const childPath = path.join(absolutePath, trimmedName);
536
+
537
+ // Validate the resulting path too — don't let "subfolder=foo/../../etc"
538
+ // bypass the parent-only check above. validateWorkspacePath already
539
+ // rejects symlink escapes and FORBIDDEN_PATHS.
540
+ const childValidation = await validateWorkspacePath(childPath);
541
+ if (!childValidation.valid) {
542
+ return res.status(400).json({
543
+ error: 'Invalid subfolder path',
544
+ details: childValidation.error,
545
+ });
546
+ }
547
+ const childAbsolute = childValidation.resolvedPath;
548
+
549
+ // Refuse to clobber an existing folder with content — user can pick
550
+ // "existing" instead. Empty/missing → mkdir.
551
+ try {
552
+ const childEntries = await fs.readdir(childAbsolute);
553
+ if (childEntries.length > 0) {
554
+ return res.status(409).json({
555
+ error: 'Subfolder already exists and is not empty',
556
+ details: `Pick a different name or open "${childAbsolute}" as an existing workspace.`,
557
+ });
558
+ }
559
+ } catch (error) {
560
+ if (error.code !== 'ENOENT') throw error;
561
+ }
562
+ await fs.mkdir(childAbsolute, { recursive: true });
563
+
564
+ let subProject;
565
+ let subAlreadyExisted = false;
566
+ try {
567
+ subProject = await addProjectManually(childAbsolute);
568
+ } catch (error) {
569
+ const msg = error?.message || '';
570
+ if (!/already configured/i.test(msg)) throw error;
571
+ subAlreadyExisted = true;
572
+ subProject = {
573
+ name: childAbsolute.replace(/[\\/:]/g, '-').replace(/\./g, '-'),
574
+ path: childAbsolute,
575
+ fullPath: childAbsolute,
576
+ displayName: trimmedName,
577
+ isManuallyAdded: true,
578
+ sessions: [],
579
+ cursorSessions: [],
580
+ };
581
+ }
582
+
583
+ return res.json({
584
+ success: true,
585
+ project: subProject,
586
+ alreadyExisted: subAlreadyExisted,
587
+ message: subAlreadyExisted
588
+ ? 'Subfolder was already registered — opening it'
589
+ : 'Subfolder created successfully',
462
590
  });
463
591
  }
464
592
 
@@ -20,17 +20,36 @@ import path from 'node:path';
20
20
  const CONFIG_FILE = path.join(os.homedir(), '.pixcode', 'provider-credentials.json');
21
21
 
22
22
  /**
23
- * Map provider id → {apiKeyEnv, baseUrlEnv} so we know which env vars to
24
- * inject into the spawn env. Cursor is OAuth-only; it has no api-key entry.
23
+ * Map provider id → {apiKeyEnv, baseUrlEnv, extraEnv?} so we know which env
24
+ * vars to inject into the spawn env. Cursor is OAuth-only; it has no api-key
25
+ * entry.
26
+ *
27
+ * `baseUrlEnv` lets users point a provider at any OpenAI-compatible (or
28
+ * Gemini-compatible) endpoint they want — third-party gateways, self-hosted
29
+ * proxies, OpenRouter, Together, etc. — without forking the CLI. The CLI
30
+ * picks the env var up natively because every supported CLI honours its
31
+ * vendor's standard variable names. **Don't rename these.** Pixcode is just
32
+ * a passthrough; people expect the same names that work outside Pixcode.
33
+ *
34
+ * `extraEnv` is a list of additional env-var names that should be mirrored
35
+ * across with the same value as `baseUrlEnv` — handy when a provider has
36
+ * historical aliases (e.g. Gemini's `GOOGLE_GEMINI_BASE_URL` vs newer
37
+ * `GEMINI_BASE_URL` clients).
25
38
  */
26
39
  export const PROVIDER_ENV_VARS = Object.freeze({
27
40
  claude: { apiKeyEnv: 'ANTHROPIC_API_KEY', baseUrlEnv: 'ANTHROPIC_BASE_URL' },
28
41
  codex: { apiKeyEnv: 'OPENAI_API_KEY', baseUrlEnv: 'OPENAI_BASE_URL' },
29
- gemini: { apiKeyEnv: 'GEMINI_API_KEY', baseUrlEnv: null },
42
+ gemini: {
43
+ apiKeyEnv: 'GEMINI_API_KEY',
44
+ baseUrlEnv: 'GOOGLE_GEMINI_BASE_URL',
45
+ // Some Gemini-API-compatible gateways pick up the shorter
46
+ // `GEMINI_BASE_URL` name; mirror so either client works.
47
+ extraBaseUrlEnv: ['GEMINI_BASE_URL'],
48
+ },
30
49
  qwen: { apiKeyEnv: 'OPENAI_API_KEY', baseUrlEnv: 'OPENAI_BASE_URL' },
31
- // OpenCode is multi-provider. Set ANTHROPIC_API_KEY by default since
32
- // Claude is OpenCode Zen's recommended backend; users wanting OpenAI
33
- // or OpenRouter can override via the opencode.json `provider` block.
50
+ // OpenCode is multi-provider. Default-set ANTHROPIC_*, but ALSO mirror
51
+ // the same key into OPENAI_API_KEY when the user picks an OpenAI-flavour
52
+ // model handled at spawn time in opencode-cli.js, not here.
34
53
  opencode: { apiKeyEnv: 'ANTHROPIC_API_KEY', baseUrlEnv: 'ANTHROPIC_BASE_URL' },
35
54
  });
36
55
 
@@ -98,7 +117,13 @@ export async function buildSpawnEnv(provider, baseEnv = process.env) {
98
117
  if (!creds) return env;
99
118
 
100
119
  if (envVars.apiKeyEnv) env[envVars.apiKeyEnv] = creds.apiKey;
101
- if (envVars.baseUrlEnv && creds.baseUrl) env[envVars.baseUrlEnv] = creds.baseUrl;
120
+ if (envVars.baseUrlEnv && creds.baseUrl) {
121
+ env[envVars.baseUrlEnv] = creds.baseUrl;
122
+ // Mirror to alias env-var names so clients that read either work.
123
+ for (const alias of envVars.extraBaseUrlEnv || []) {
124
+ env[alias] = creds.baseUrl;
125
+ }
126
+ }
102
127
  return env;
103
128
  }
104
129
 
@@ -116,7 +141,12 @@ export async function applyAllStoredCredentialsToEnv() {
116
141
  const apiKey = typeof entry.apiKey === 'string' ? entry.apiKey.trim() : '';
117
142
  const baseUrl = typeof entry.baseUrl === 'string' ? entry.baseUrl.trim() : '';
118
143
  if (envVars.apiKeyEnv && apiKey) process.env[envVars.apiKeyEnv] = apiKey;
119
- if (envVars.baseUrlEnv && baseUrl) process.env[envVars.baseUrlEnv] = baseUrl;
144
+ if (envVars.baseUrlEnv && baseUrl) {
145
+ process.env[envVars.baseUrlEnv] = baseUrl;
146
+ for (const alias of envVars.extraBaseUrlEnv || []) {
147
+ process.env[alias] = baseUrl;
148
+ }
149
+ }
120
150
  }
121
151
  }
122
152
 
@@ -136,6 +166,10 @@ export async function applyProviderCredentialsToEnv(provider) {
136
166
  if (envVars.baseUrlEnv) {
137
167
  if (creds?.baseUrl) process.env[envVars.baseUrlEnv] = creds.baseUrl;
138
168
  else delete process.env[envVars.baseUrlEnv];
169
+ for (const alias of envVars.extraBaseUrlEnv || []) {
170
+ if (creds?.baseUrl) process.env[alias] = creds.baseUrl;
171
+ else delete process.env[alias];
172
+ }
139
173
  }
140
174
  }
141
175