@mmmbuto/nexuscli 0.9.6 → 0.9.7-termux

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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * CliLoader - Unified message loader for TRI CLI (Claude/Codex/Gemini)
2
+ * CliLoader - Unified message loader for TRI CLI (Claude/Codex/Gemini/Qwen)
3
3
  *
4
4
  * Loads messages on-demand from CLI history files (lazy loading).
5
5
  * Filesystem is the source of truth - no DB caching of messages.
@@ -8,6 +8,7 @@
8
8
  * - Claude: ~/.claude/projects/<workspace-slug>/<sessionId>.jsonl
9
9
  * - Codex: ~/.codex/sessions/<sessionId>.jsonl (if available)
10
10
  * - Gemini: ~/.gemini/sessions/<sessionId>.jsonl (if available)
11
+ * - Qwen : ~/.qwen/projects/<sanitized>/chats/<sessionId>.jsonl
11
12
  *
12
13
  * @version 0.4.0 - TRI CLI Support
13
14
  */
@@ -23,6 +24,7 @@ const ENGINE_PATHS = {
23
24
  claude: path.join(process.env.HOME || '', '.claude'),
24
25
  codex: path.join(process.env.HOME || '', '.codex'),
25
26
  gemini: path.join(process.env.HOME || '', '.gemini'),
27
+ qwen: path.join(process.env.HOME || '', '.qwen'),
26
28
  };
27
29
 
28
30
  class CliLoader {
@@ -30,15 +32,16 @@ class CliLoader {
30
32
  this.claudePath = ENGINE_PATHS.claude;
31
33
  this.codexPath = ENGINE_PATHS.codex;
32
34
  this.geminiPath = ENGINE_PATHS.gemini;
35
+ this.qwenPath = ENGINE_PATHS.qwen;
33
36
  }
34
37
 
35
38
  /**
36
39
  * Load messages from CLI history by session.
37
- * Supports all three engines: Claude, Codex, Gemini.
40
+ * Supports all engines: Claude, Codex, Gemini, Qwen.
38
41
  *
39
42
  * @param {Object} params
40
43
  * @param {string} params.sessionId - Session UUID
41
- * @param {string} params.engine - 'claude'|'claude-code'|'codex'|'gemini'
44
+ * @param {string} params.engine - 'claude'|'claude-code'|'codex'|'gemini'|'qwen'
42
45
  * @param {string} params.workspacePath - Workspace directory (required for Claude)
43
46
  * @param {number} [params.limit=30] - Max messages to return
44
47
  * @param {number} [params.before] - Timestamp cursor for pagination (ms)
@@ -76,6 +79,9 @@ class CliLoader {
76
79
  case 'gemini':
77
80
  result = await this.loadGeminiMessages({ sessionId, nativeId, limit, before, mode });
78
81
  break;
82
+ case 'qwen':
83
+ result = await this.loadQwenMessages({ sessionId, nativeId, workspacePath, limit, before, mode });
84
+ break;
79
85
 
80
86
  default:
81
87
  throw new Error(`Unsupported engine: ${engine}`);
@@ -94,6 +100,7 @@ class CliLoader {
94
100
  if (lower.includes('claude')) return 'claude';
95
101
  if (lower.includes('codex') || lower.includes('openai')) return 'codex';
96
102
  if (lower.includes('gemini') || lower.includes('google')) return 'gemini';
103
+ if (lower.includes('qwen')) return 'qwen';
97
104
  return lower;
98
105
  }
99
106
 
@@ -108,6 +115,14 @@ class CliLoader {
108
115
  return workspacePath.replace(/[\/\.]/g, '-');
109
116
  }
110
117
 
118
+ /**
119
+ * Convert workspace path to Qwen project dir (matches Qwen Storage.sanitizeCwd)
120
+ */
121
+ qwenPathToProject(workspacePath) {
122
+ if (!workspacePath) return 'default';
123
+ return workspacePath.replace(/[^a-zA-Z0-9]/g, '-');
124
+ }
125
+
111
126
  // ============================================================
112
127
  // CLAUDE - Load from ~/.claude/projects/<slug>/<sessionId>.jsonl
113
128
  // ============================================================
@@ -368,6 +383,67 @@ class CliLoader {
368
383
  return this._paginateMessages(messages, limit, before, mode);
369
384
  }
370
385
 
386
+ // ============================================================
387
+ // QWEN - Load from ~/.qwen/projects/<sanitized>/chats/<sessionId>.jsonl
388
+ // ============================================================
389
+
390
+ async loadQwenMessages({ sessionId, nativeId, workspacePath, limit, before, mode }) {
391
+ if (!workspacePath) {
392
+ console.warn('[CliLoader] No workspacePath for Qwen, using cwd');
393
+ workspacePath = process.cwd();
394
+ }
395
+
396
+ const project = this.qwenPathToProject(workspacePath);
397
+ const fileId = nativeId || sessionId;
398
+ const sessionFile = path.join(this.qwenPath, 'projects', project, 'chats', `${fileId}.jsonl`);
399
+
400
+ if (!fs.existsSync(sessionFile)) {
401
+ console.log(`[CliLoader] Qwen session file not found: ${sessionFile}`);
402
+ return this._emptyResult();
403
+ }
404
+
405
+ const rawMessages = await this._parseJsonlFile(sessionFile);
406
+
407
+ const messages = rawMessages
408
+ .filter(entry => entry.type === 'user' || entry.type === 'assistant')
409
+ .map(entry => this._normalizeQwenEntry(entry));
410
+
411
+ return this._paginateMessages(messages, limit, before, mode);
412
+ }
413
+
414
+ /**
415
+ * Normalize Qwen session entry to message shape
416
+ */
417
+ _normalizeQwenEntry(entry) {
418
+ const role = entry.type || 'assistant';
419
+ const created_at = entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now();
420
+
421
+ let content = '';
422
+ const parts = entry.message?.parts;
423
+ if (Array.isArray(parts)) {
424
+ content = parts
425
+ .filter(p => p && p.text)
426
+ .map(p => p.text)
427
+ .join('\\n');
428
+ } else if (typeof entry.message?.content === 'string') {
429
+ content = entry.message.content;
430
+ } else if (entry.text) {
431
+ content = entry.text;
432
+ }
433
+
434
+ return {
435
+ id: entry.uuid || `qwen-${created_at}`,
436
+ role,
437
+ content,
438
+ engine: 'qwen',
439
+ created_at,
440
+ metadata: {
441
+ model: entry.model,
442
+ usage: entry.usageMetadata
443
+ }
444
+ };
445
+ }
446
+
371
447
  /**
372
448
  * Normalize Gemini session entry to message shape
373
449
  */
@@ -500,6 +576,10 @@ class CliLoader {
500
576
 
501
577
  case 'gemini':
502
578
  return path.join(this.geminiPath, 'sessions', `${sessionId}.jsonl`);
579
+ case 'qwen': {
580
+ const project = this.qwenPathToProject(workspacePath);
581
+ return path.join(this.qwenPath, 'projects', project, 'chats', `${sessionId}.jsonl`);
582
+ }
503
583
 
504
584
  default:
505
585
  return null;
@@ -21,7 +21,8 @@ const ENGINE_LIMITS = {
21
21
  'claude': { maxTokens: 4000, preferSummary: true },
22
22
  'codex': { maxTokens: 3000, preferSummary: true, codeOnly: true },
23
23
  'deepseek': { maxTokens: 3000, preferSummary: true },
24
- 'gemini': { maxTokens: 6000, preferSummary: false } // Gemini has large context
24
+ 'gemini': { maxTokens: 6000, preferSummary: false }, // Gemini has large context
25
+ 'qwen': { maxTokens: 6000, preferSummary: false } // Qwen Coder large context
25
26
  };
26
27
 
27
28
  class ContextBridge {
@@ -152,7 +153,8 @@ class ContextBridge {
152
153
  'claude': 'Claude Code (Anthropic)',
153
154
  'codex': 'Codex (OpenAI)',
154
155
  'gemini': 'Gemini (Google)',
155
- 'deepseek': 'DeepSeek'
156
+ 'deepseek': 'DeepSeek',
157
+ 'qwen': 'Qwen Code (Alibaba)'
156
158
  };
157
159
 
158
160
  const fromName = engineNames[fromEngine] || fromEngine;
@@ -0,0 +1,289 @@
1
+ /**
2
+ * QwenOutputParser - Parse Qwen CLI stream-json output
3
+ *
4
+ * Qwen Code stream-json emits JSONL lines with message envelopes:
5
+ * - system (subtype: init)
6
+ * - assistant (message.content blocks)
7
+ * - user (tool_result blocks)
8
+ * - stream_event (partial deltas when enabled)
9
+ * - result (usage + status)
10
+ *
11
+ * Emits normalized events for SSE streaming:
12
+ * - status: { type: 'status', category: 'tool'|'system', message, icon }
13
+ * - response_chunk: { type: 'response_chunk', text, isIncremental }
14
+ * - response_done: { type: 'response_done', fullText }
15
+ * - done: { type: 'done', usage, status }
16
+ * - error: { type: 'error', message }
17
+ */
18
+
19
+ class QwenOutputParser {
20
+ constructor() {
21
+ this.buffer = '';
22
+ this.finalResponse = '';
23
+ this.usage = null;
24
+ this.sessionId = null;
25
+ this.model = null;
26
+ this.receivedPartial = false;
27
+ }
28
+
29
+ /**
30
+ * Parse stdout chunk (may contain multiple JSON lines)
31
+ * @param {string} chunk
32
+ * @returns {Array}
33
+ */
34
+ parse(chunk) {
35
+ const events = [];
36
+
37
+ this.buffer += chunk;
38
+ const lines = this.buffer.split('\n');
39
+ this.buffer = lines.pop() || '';
40
+
41
+ for (const line of lines) {
42
+ const trimmed = line.trim();
43
+ if (!trimmed) continue;
44
+
45
+ if (!trimmed.startsWith('{')) {
46
+ continue;
47
+ }
48
+
49
+ try {
50
+ const json = JSON.parse(trimmed);
51
+ const lineEvents = this._parseJsonEvent(json);
52
+ events.push(...lineEvents);
53
+ } catch (e) {
54
+ console.warn('[QwenOutputParser] JSON parse error:', e.message, '- Line:', trimmed.substring(0, 80));
55
+ }
56
+ }
57
+
58
+ return events;
59
+ }
60
+
61
+ _parseJsonEvent(event) {
62
+ const events = [];
63
+
64
+ switch (event.type) {
65
+ case 'system': {
66
+ if (event.subtype === 'init') {
67
+ this.sessionId = event.session_id || event.sessionId || this.sessionId;
68
+ this.model = event.model || event.data?.model || this.model;
69
+ events.push({
70
+ type: 'status',
71
+ category: 'system',
72
+ message: 'Session initialized',
73
+ icon: '🚀',
74
+ sessionId: this.sessionId,
75
+ model: this.model,
76
+ timestamp: event.timestamp || new Date().toISOString(),
77
+ });
78
+ }
79
+ break;
80
+ }
81
+
82
+ case 'assistant': {
83
+ const contentBlocks = event.message?.content;
84
+ const text = this._extractText(contentBlocks);
85
+ if (text) {
86
+ if (!this.receivedPartial) {
87
+ this.finalResponse += text;
88
+ events.push({
89
+ type: 'response_chunk',
90
+ text,
91
+ isIncremental: false,
92
+ });
93
+ } else if (!this.finalResponse) {
94
+ // Fallback if partials were emitted but response was empty
95
+ this.finalResponse = text;
96
+ }
97
+ }
98
+ this._emitToolUseFromBlocks(contentBlocks, events);
99
+ break;
100
+ }
101
+
102
+ case 'user': {
103
+ const contentBlocks = event.message?.content;
104
+ this._emitToolResultFromBlocks(contentBlocks, events);
105
+ break;
106
+ }
107
+
108
+ case 'stream_event': {
109
+ const stream = event.event || {};
110
+ if (stream.type === 'content_block_delta' && stream.delta?.type === 'text_delta') {
111
+ const text = stream.delta.text || '';
112
+ if (text) {
113
+ this.receivedPartial = true;
114
+ this.finalResponse += text;
115
+ events.push({
116
+ type: 'response_chunk',
117
+ text,
118
+ isIncremental: true,
119
+ });
120
+ }
121
+ }
122
+ if (stream.type === 'content_block_start' && stream.content_block?.type === 'tool_use') {
123
+ events.push(this._formatToolUseEvent(stream.content_block));
124
+ }
125
+ break;
126
+ }
127
+
128
+ case 'result': {
129
+ if (event.is_error) {
130
+ const message = event.error?.message || event.error || 'Unknown error';
131
+ events.push({ type: 'error', message });
132
+ break;
133
+ }
134
+
135
+ this.usage = event.usage || null;
136
+ const fullText = this.finalResponse || event.result || '';
137
+
138
+ events.push({
139
+ type: 'response_done',
140
+ fullText,
141
+ });
142
+
143
+ const promptTokens = this.usage?.input_tokens || 0;
144
+ const completionTokens = this.usage?.output_tokens || 0;
145
+ const totalTokens = this.usage?.total_tokens || (promptTokens + completionTokens);
146
+
147
+ events.push({
148
+ type: 'done',
149
+ status: 'success',
150
+ usage: {
151
+ prompt_tokens: promptTokens,
152
+ completion_tokens: completionTokens,
153
+ total_tokens: totalTokens,
154
+ },
155
+ duration_ms: event.duration_ms || 0,
156
+ sessionId: this.sessionId,
157
+ });
158
+ break;
159
+ }
160
+
161
+ default:
162
+ // Ignore other event types
163
+ break;
164
+ }
165
+
166
+ return events;
167
+ }
168
+
169
+ _extractText(contentBlocks) {
170
+ if (!contentBlocks) return '';
171
+ if (typeof contentBlocks === 'string') return contentBlocks;
172
+ if (!Array.isArray(contentBlocks)) return '';
173
+
174
+ return contentBlocks
175
+ .filter((block) => block?.type === 'text' && block.text)
176
+ .map((block) => block.text)
177
+ .join('');
178
+ }
179
+
180
+ _emitToolUseFromBlocks(contentBlocks, events) {
181
+ if (!Array.isArray(contentBlocks)) return;
182
+ for (const block of contentBlocks) {
183
+ if (block?.type === 'tool_use') {
184
+ events.push(this._formatToolUseEvent(block));
185
+ }
186
+ }
187
+ }
188
+
189
+ _emitToolResultFromBlocks(contentBlocks, events) {
190
+ if (!Array.isArray(contentBlocks)) return;
191
+ for (const block of contentBlocks) {
192
+ if (block?.type === 'tool_result') {
193
+ const success = !block.is_error;
194
+ events.push({
195
+ type: 'status',
196
+ category: 'tool',
197
+ message: success ? 'Tool completed' : 'Tool failed',
198
+ icon: success ? '✅' : '❌',
199
+ toolOutput: this._truncateOutput(block.content),
200
+ timestamp: new Date().toISOString(),
201
+ });
202
+ }
203
+ }
204
+ }
205
+
206
+ _formatToolUseEvent(block) {
207
+ const tool = block?.name || block?.tool || block?.function?.name || 'Tool';
208
+ const input = block?.input || block?.parameters || block?.args || block?.function?.arguments || {};
209
+ let message = tool;
210
+
211
+ switch (tool) {
212
+ case 'shell':
213
+ case 'run_shell_command':
214
+ case 'execute_command':
215
+ message = `Shell: ${this._truncate(block.command || input.command || '', 60)}`;
216
+ break;
217
+ case 'read_file':
218
+ case 'read_many_files':
219
+ case 'read':
220
+ message = `Reading: ${this._truncate(block.path || input.path || input.file_path || '', 50)}`;
221
+ break;
222
+ case 'write_file':
223
+ case 'write':
224
+ message = `Writing: ${this._truncate(block.path || input.path || input.file_path || '', 50)}`;
225
+ break;
226
+ case 'edit_file':
227
+ case 'edit':
228
+ message = `Editing: ${this._truncate(block.path || input.path || input.file_path || '', 50)}`;
229
+ break;
230
+ case 'search_files':
231
+ case 'grep':
232
+ case 'find_files':
233
+ message = `Searching: ${this._truncate(block.pattern || input.pattern || input.query || '', 40)}`;
234
+ break;
235
+ case 'list_directory':
236
+ case 'list_dir':
237
+ case 'ls':
238
+ message = `Listing: ${this._truncate(block.path || input.path || input.dir_path || '.', 50)}`;
239
+ break;
240
+ case 'web_search':
241
+ case 'google_search':
242
+ case 'search':
243
+ message = `Web search: ${this._truncate(block.query || input.query || '', 40)}`;
244
+ break;
245
+ case 'web_fetch':
246
+ case 'fetch_url':
247
+ message = `Fetch: ${this._truncate(block.url || input.url || '', 60)}`;
248
+ break;
249
+ default:
250
+ message = `Tool: ${tool}`;
251
+ break;
252
+ }
253
+
254
+ return {
255
+ type: 'status',
256
+ category: 'tool',
257
+ message,
258
+ icon: '🛠️',
259
+ };
260
+ }
261
+
262
+ _truncate(text, maxLen = 60) {
263
+ if (!text) return '';
264
+ const str = String(text);
265
+ if (str.length <= maxLen) return str;
266
+ return str.substring(0, maxLen) + '...';
267
+ }
268
+
269
+ _truncateOutput(output, maxLen = 200) {
270
+ if (!output) return '';
271
+ const text = typeof output === 'string' ? output : JSON.stringify(output);
272
+ if (text.length <= maxLen) return text;
273
+ return text.substring(0, maxLen) + '...';
274
+ }
275
+
276
+ getFinalResponse() {
277
+ return this.finalResponse || '';
278
+ }
279
+
280
+ getUsage() {
281
+ return this.usage;
282
+ }
283
+
284
+ getSessionId() {
285
+ return this.sessionId;
286
+ }
287
+ }
288
+
289
+ module.exports = QwenOutputParser;
@@ -0,0 +1,251 @@
1
+ /**
2
+ * QwenWrapper - Wrapper for Qwen Code CLI (qwen command)
3
+ *
4
+ * Executes Qwen CLI with PTY adapter for Termux.
5
+ * Uses stream-json output for structured parsing.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const pty = require('../lib/pty-adapter');
11
+ const QwenOutputParser = require('./qwen-output-parser');
12
+ const BaseCliWrapper = require('./base-cli-wrapper');
13
+
14
+ const DEFAULT_MODEL = 'coder-model';
15
+ const CLI_TIMEOUT_MS = 600000; // 10 minutes
16
+
17
+ const DANGEROUS_PATTERNS = [
18
+ /pkill\s+(-\d+\s+)?node/i,
19
+ /killall\s+(-\d+\s+)?node/i,
20
+ /kill\s+-9/i,
21
+ /rm\s+-rf\s+[\/~]/i,
22
+ /shutdown/i,
23
+ /reboot/i,
24
+ /systemctl\s+(stop|restart|disable)/i,
25
+ /service\s+\w+\s+(stop|restart)/i,
26
+ />\s*\/dev\/sd/i,
27
+ /mkfs/i,
28
+ /dd\s+if=.*of=\/dev/i,
29
+ ];
30
+
31
+ class QwenWrapper extends BaseCliWrapper {
32
+ constructor(options = {}) {
33
+ super();
34
+ this.qwenPath = this._resolveQwenPath(options.qwenPath);
35
+ this.workspaceDir = options.workspaceDir || process.cwd();
36
+
37
+ console.log(`[QwenWrapper] Initialized with binary: ${this.qwenPath}`);
38
+ }
39
+
40
+ _isDangerousCommand(command) {
41
+ if (!command) return false;
42
+ for (const pattern of DANGEROUS_PATTERNS) {
43
+ if (pattern.test(command)) return true;
44
+ }
45
+ return false;
46
+ }
47
+
48
+ _resolveQwenPath(overridePath) {
49
+ if (overridePath && fs.existsSync(overridePath)) return overridePath;
50
+
51
+ const candidates = [
52
+ path.join(process.env.HOME || '', '.local/bin/qwen'),
53
+ path.join(process.env.PREFIX || '', 'bin/qwen'),
54
+ '/usr/local/bin/qwen',
55
+ '/usr/bin/qwen',
56
+ 'qwen',
57
+ ];
58
+
59
+ for (const candidate of candidates) {
60
+ try {
61
+ if (candidate === 'qwen' || fs.existsSync(candidate)) {
62
+ return candidate;
63
+ }
64
+ } catch (_) {
65
+ // ignore
66
+ }
67
+ }
68
+
69
+ return 'qwen';
70
+ }
71
+
72
+ /**
73
+ * Send a message to Qwen CLI
74
+ * @param {Object} params
75
+ * @param {string} params.prompt
76
+ * @param {string} params.threadId - Native Qwen session ID for resume
77
+ * @param {string} params.model
78
+ * @param {string} params.workspacePath
79
+ * @param {Function} params.onStatus
80
+ */
81
+ async sendMessage({
82
+ prompt,
83
+ threadId,
84
+ model = DEFAULT_MODEL,
85
+ workspacePath,
86
+ onStatus,
87
+ processId: processIdOverride
88
+ }) {
89
+ return new Promise((resolve, reject) => {
90
+ const parser = new QwenOutputParser();
91
+ let promiseSettled = false;
92
+
93
+ const cwd = workspacePath || this.workspaceDir;
94
+
95
+ const args = [
96
+ '-y',
97
+ '-m', model,
98
+ '-o', 'stream-json',
99
+ '--include-partial-messages',
100
+ ];
101
+
102
+ if (threadId) {
103
+ args.push('--resume', threadId);
104
+ }
105
+
106
+ args.push(prompt);
107
+
108
+ console.log(`[QwenWrapper] Model: ${model}`);
109
+ console.log(`[QwenWrapper] ThreadId: ${threadId || '(new session)'}`);
110
+ console.log(`[QwenWrapper] CWD: ${cwd}`);
111
+ console.log(`[QwenWrapper] Prompt length: ${prompt.length}`);
112
+
113
+ let ptyProcess;
114
+ try {
115
+ ptyProcess = pty.spawn(this.qwenPath, args, {
116
+ name: 'xterm-color',
117
+ cols: 120,
118
+ rows: 40,
119
+ cwd,
120
+ env: {
121
+ ...process.env,
122
+ TERM: 'xterm-256color',
123
+ }
124
+ });
125
+ } catch (spawnError) {
126
+ return reject(new Error(`Failed to spawn Qwen CLI: ${spawnError.message}`));
127
+ }
128
+
129
+ const processId = processIdOverride || threadId || `qwen-${Date.now()}`;
130
+ this.registerProcess(processId, ptyProcess, 'pty');
131
+
132
+ let stdout = '';
133
+
134
+ ptyProcess.onData((data) => {
135
+ stdout += data;
136
+
137
+ const cleanData = data
138
+ .replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '')
139
+ .replace(/\x1B\[\?[0-9;]*[a-zA-Z]/g, '')
140
+ .replace(/\r/g, '');
141
+
142
+ if (onStatus && cleanData.trim()) {
143
+ try {
144
+ const events = parser.parse(cleanData);
145
+ events.forEach(event => {
146
+ if (event.type === 'status' && event.message) {
147
+ const msg = event.message;
148
+ if (msg.startsWith('Shell:') || msg.includes('Shell:')) {
149
+ const shellCmd = msg.replace(/^Shell:\s*/, '');
150
+ if (this._isDangerousCommand(shellCmd)) {
151
+ console.warn(`[QwenWrapper] ⚠️ DANGEROUS COMMAND DETECTED: ${shellCmd.substring(0, 100)}`);
152
+ }
153
+ }
154
+ }
155
+ onStatus(event);
156
+ });
157
+ } catch (parseError) {
158
+ console.error('[QwenWrapper] Parser error:', parseError.message);
159
+ }
160
+ }
161
+ });
162
+
163
+ ptyProcess.onExit(({ exitCode }) => {
164
+ this.unregisterProcess(processId);
165
+
166
+ if (promiseSettled) return;
167
+ promiseSettled = true;
168
+
169
+ if (exitCode !== 0 && exitCode !== null) {
170
+ reject(new Error(`Qwen CLI error (exit ${exitCode}): ${stdout.substring(0, 200)}`));
171
+ return;
172
+ }
173
+
174
+ const finalResponse = parser.getFinalResponse();
175
+ const usage = parser.getUsage();
176
+
177
+ const promptTokens = usage?.input_tokens || Math.ceil(prompt.length / 4);
178
+ const completionTokens = usage?.output_tokens || Math.ceil(finalResponse.length / 4);
179
+
180
+ if (!finalResponse.trim()) {
181
+ return reject(new Error('Qwen CLI returned empty response. Check CLI logs.'));
182
+ }
183
+
184
+ resolve({
185
+ text: finalResponse,
186
+ sessionId: parser.getSessionId(),
187
+ usage: {
188
+ prompt_tokens: promptTokens,
189
+ completion_tokens: completionTokens,
190
+ total_tokens: promptTokens + completionTokens,
191
+ }
192
+ });
193
+ });
194
+
195
+ if (ptyProcess.onError) {
196
+ ptyProcess.onError((error) => {
197
+ if (promiseSettled) return;
198
+ promiseSettled = true;
199
+ reject(new Error(`Qwen CLI PTY error: ${error.message}`));
200
+ });
201
+ }
202
+
203
+ const timeout = setTimeout(() => {
204
+ if (!promiseSettled) {
205
+ promiseSettled = true;
206
+ try { ptyProcess.kill(); } catch (_) {}
207
+ reject(new Error('Qwen CLI timeout (10 minutes)'));
208
+ }
209
+ }, CLI_TIMEOUT_MS);
210
+
211
+ ptyProcess.onExit(() => clearTimeout(timeout));
212
+ });
213
+ }
214
+
215
+ async isAvailable() {
216
+ return new Promise((resolve) => {
217
+ const { exec } = require('child_process');
218
+ exec(`${this.qwenPath} --version`, { timeout: 5000 }, (error, stdout) => {
219
+ if (error) {
220
+ console.log('[QwenWrapper] CLI not available:', error.message);
221
+ resolve(false);
222
+ } else {
223
+ console.log('[QwenWrapper] CLI available:', stdout.trim().substring(0, 50));
224
+ resolve(true);
225
+ }
226
+ });
227
+ });
228
+ }
229
+
230
+ getDefaultModel() {
231
+ return DEFAULT_MODEL;
232
+ }
233
+
234
+ getAvailableModels() {
235
+ return [
236
+ {
237
+ id: 'coder-model',
238
+ name: 'coder-model',
239
+ description: '🔧 Qwen Coder (default)',
240
+ default: true
241
+ },
242
+ {
243
+ id: 'vision-model',
244
+ name: 'vision-model',
245
+ description: '👁️ Qwen Vision'
246
+ }
247
+ ];
248
+ }
249
+ }
250
+
251
+ module.exports = QwenWrapper;