@pixelbyte-software/pixcode 1.33.8 → 1.33.10
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.
- package/dist/api-docs.html +395 -879
- package/dist/assets/{index-JU38YIxa.js → index-B_dU5AHA.js} +153 -165
- package/dist/favicon.svg +8 -8
- package/dist/icons/icon-128x128.svg +9 -9
- package/dist/icons/icon-144x144.svg +9 -9
- package/dist/icons/icon-152x152.svg +9 -9
- package/dist/icons/icon-192x192.svg +9 -9
- package/dist/icons/icon-384x384.svg +9 -9
- package/dist/icons/icon-512x512.svg +9 -9
- package/dist/icons/icon-72x72.svg +9 -9
- package/dist/icons/icon-96x96.svg +9 -9
- package/dist/icons/icon-template.svg +9 -9
- package/dist/index.html +1 -1
- package/dist/logo.svg +12 -12
- package/dist/openapi.yaml +1311 -0
- package/dist-server/server/gemini-cli.js +59 -0
- package/dist-server/server/gemini-cli.js.map +1 -1
- package/dist-server/server/index.js +6 -1
- package/dist-server/server/index.js.map +1 -1
- package/dist-server/server/middleware/auth.js +51 -9
- package/dist-server/server/middleware/auth.js.map +1 -1
- package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js +54 -15
- package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/qwen/qwen-sessions.provider.js +46 -0
- package/dist-server/server/modules/providers/list/qwen/qwen-sessions.provider.js.map +1 -1
- package/dist-server/server/modules/providers/provider.routes.js +32 -1
- package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
- package/dist-server/server/opencode-cli.js +37 -1
- package/dist-server/server/opencode-cli.js.map +1 -1
- package/dist-server/server/opencode-response-handler.js +36 -34
- package/dist-server/server/opencode-response-handler.js.map +1 -1
- package/dist-server/server/routes/agent.js +187 -56
- package/dist-server/server/routes/agent.js.map +1 -1
- package/dist-server/server/routes/projects.js +134 -8
- package/dist-server/server/routes/projects.js.map +1 -1
- package/dist-server/server/services/provider-credentials.js +42 -8
- package/dist-server/server/services/provider-credentials.js.map +1 -1
- package/package.json +178 -178
- package/scripts/rest-sweep.mjs +93 -0
- package/server/database/db.js +794 -794
- package/server/database/json-store.js +194 -194
- package/server/gemini-cli.js +60 -0
- package/server/index.js +6 -1
- package/server/middleware/auth.js +50 -9
- package/server/modules/providers/list/opencode/opencode-auth.provider.ts +130 -130
- package/server/modules/providers/list/opencode/opencode-mcp.provider.ts +126 -126
- package/server/modules/providers/list/opencode/opencode-sessions.provider.ts +232 -193
- package/server/modules/providers/list/opencode/opencode.provider.ts +29 -29
- package/server/modules/providers/list/qwen/qwen-auth.provider.ts +145 -145
- package/server/modules/providers/list/qwen/qwen-mcp.provider.ts +114 -114
- package/server/modules/providers/list/qwen/qwen-sessions.provider.ts +265 -218
- package/server/modules/providers/list/qwen/qwen.provider.ts +21 -21
- package/server/modules/providers/provider.routes.ts +37 -4
- package/server/modules/providers/shared/provider-configs.ts +142 -142
- package/server/opencode-cli.js +37 -1
- package/server/opencode-response-handler.js +107 -100
- package/server/qwen-code-cli.js +395 -395
- package/server/qwen-response-handler.js +73 -73
- package/server/routes/agent.js +178 -58
- package/server/routes/projects.js +136 -8
- package/server/routes/qwen.js +27 -27
- package/server/services/external-access.js +171 -171
- package/server/services/provider-credentials.js +189 -155
- package/server/services/provider-models.js +381 -381
- package/server/services/telegram/telegram-http-client.js +130 -130
- package/server/services/vapid-keys.js +36 -36
- package/server/utils/port-access.js +209 -209
|
@@ -1,73 +1,73 @@
|
|
|
1
|
-
// Qwen Code Response Handler — stream-json parser.
|
|
2
|
-
// Qwen Code is a fork of Gemini CLI and emits the same NDJSON stream-json
|
|
3
|
-
// event shape (type: init | message | tool_use | tool_result | result | error).
|
|
4
|
-
// This handler is intentionally a structural twin of gemini-response-handler;
|
|
5
|
-
// the split keeps provider normalization clean and lets the two evolve
|
|
6
|
-
// independently if Qwen's protocol ever diverges.
|
|
7
|
-
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
|
8
|
-
|
|
9
|
-
class QwenResponseHandler {
|
|
10
|
-
constructor(ws, options = {}) {
|
|
11
|
-
this.ws = ws;
|
|
12
|
-
this.buffer = '';
|
|
13
|
-
this.onContentFragment = options.onContentFragment || null;
|
|
14
|
-
this.onInit = options.onInit || null;
|
|
15
|
-
this.onToolUse = options.onToolUse || null;
|
|
16
|
-
this.onToolResult = options.onToolResult || null;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
processData(data) {
|
|
20
|
-
this.buffer += data;
|
|
21
|
-
|
|
22
|
-
const lines = this.buffer.split('\n');
|
|
23
|
-
this.buffer = lines.pop() || '';
|
|
24
|
-
|
|
25
|
-
for (const line of lines) {
|
|
26
|
-
if (!line.trim()) continue;
|
|
27
|
-
try {
|
|
28
|
-
const event = JSON.parse(line);
|
|
29
|
-
this.handleEvent(event);
|
|
30
|
-
} catch {
|
|
31
|
-
// Not a JSON line — debug output or CLI warning, ignore.
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
handleEvent(event) {
|
|
37
|
-
const sid = typeof this.ws.getSessionId === 'function' ? this.ws.getSessionId() : null;
|
|
38
|
-
|
|
39
|
-
if (event.type === 'init') {
|
|
40
|
-
if (this.onInit) this.onInit(event);
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
if (event.type === 'message' && event.role === 'assistant') {
|
|
45
|
-
const content = event.content || '';
|
|
46
|
-
if (this.onContentFragment && content) this.onContentFragment(content);
|
|
47
|
-
} else if (event.type === 'tool_use' && this.onToolUse) {
|
|
48
|
-
this.onToolUse(event);
|
|
49
|
-
} else if (event.type === 'tool_result' && this.onToolResult) {
|
|
50
|
-
this.onToolResult(event);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const normalized = sessionsService.normalizeMessage('qwen', event, sid);
|
|
54
|
-
for (const msg of normalized) {
|
|
55
|
-
this.ws.send(msg);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
forceFlush() {
|
|
60
|
-
if (this.buffer.trim()) {
|
|
61
|
-
try {
|
|
62
|
-
const event = JSON.parse(this.buffer);
|
|
63
|
-
this.handleEvent(event);
|
|
64
|
-
} catch { /* ignore */ }
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
destroy() {
|
|
69
|
-
this.buffer = '';
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export default QwenResponseHandler;
|
|
1
|
+
// Qwen Code Response Handler — stream-json parser.
|
|
2
|
+
// Qwen Code is a fork of Gemini CLI and emits the same NDJSON stream-json
|
|
3
|
+
// event shape (type: init | message | tool_use | tool_result | result | error).
|
|
4
|
+
// This handler is intentionally a structural twin of gemini-response-handler;
|
|
5
|
+
// the split keeps provider normalization clean and lets the two evolve
|
|
6
|
+
// independently if Qwen's protocol ever diverges.
|
|
7
|
+
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
|
8
|
+
|
|
9
|
+
class QwenResponseHandler {
|
|
10
|
+
constructor(ws, options = {}) {
|
|
11
|
+
this.ws = ws;
|
|
12
|
+
this.buffer = '';
|
|
13
|
+
this.onContentFragment = options.onContentFragment || null;
|
|
14
|
+
this.onInit = options.onInit || null;
|
|
15
|
+
this.onToolUse = options.onToolUse || null;
|
|
16
|
+
this.onToolResult = options.onToolResult || null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
processData(data) {
|
|
20
|
+
this.buffer += data;
|
|
21
|
+
|
|
22
|
+
const lines = this.buffer.split('\n');
|
|
23
|
+
this.buffer = lines.pop() || '';
|
|
24
|
+
|
|
25
|
+
for (const line of lines) {
|
|
26
|
+
if (!line.trim()) continue;
|
|
27
|
+
try {
|
|
28
|
+
const event = JSON.parse(line);
|
|
29
|
+
this.handleEvent(event);
|
|
30
|
+
} catch {
|
|
31
|
+
// Not a JSON line — debug output or CLI warning, ignore.
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
handleEvent(event) {
|
|
37
|
+
const sid = typeof this.ws.getSessionId === 'function' ? this.ws.getSessionId() : null;
|
|
38
|
+
|
|
39
|
+
if (event.type === 'init') {
|
|
40
|
+
if (this.onInit) this.onInit(event);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (event.type === 'message' && event.role === 'assistant') {
|
|
45
|
+
const content = event.content || '';
|
|
46
|
+
if (this.onContentFragment && content) this.onContentFragment(content);
|
|
47
|
+
} else if (event.type === 'tool_use' && this.onToolUse) {
|
|
48
|
+
this.onToolUse(event);
|
|
49
|
+
} else if (event.type === 'tool_result' && this.onToolResult) {
|
|
50
|
+
this.onToolResult(event);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const normalized = sessionsService.normalizeMessage('qwen', event, sid);
|
|
54
|
+
for (const msg of normalized) {
|
|
55
|
+
this.ws.send(msg);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
forceFlush() {
|
|
60
|
+
if (this.buffer.trim()) {
|
|
61
|
+
try {
|
|
62
|
+
const event = JSON.parse(this.buffer);
|
|
63
|
+
this.handleEvent(event);
|
|
64
|
+
} catch { /* ignore */ }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
destroy() {
|
|
69
|
+
this.buffer = '';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export default QwenResponseHandler;
|
package/server/routes/agent.js
CHANGED
|
@@ -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:
|
|
48
|
-
|
|
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
|
|
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
|
|
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
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
//
|
|
544
|
-
if (
|
|
545
|
-
|
|
546
|
-
|
|
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
|
|
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
|
|
565
|
-
let
|
|
566
|
-
let
|
|
567
|
-
let
|
|
568
|
-
|
|
569
|
-
for (const
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
if (
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
//
|
|
582
|
-
if (data
|
|
583
|
-
const
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
|
596
|
-
outputTokens
|
|
597
|
-
cacheReadTokens
|
|
598
|
-
cacheCreationTokens
|
|
599
|
-
totalTokens:
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
424
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|