@mmmbuto/nexuscli 0.9.5 → 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.
@@ -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;
@@ -5,6 +5,7 @@
5
5
  * - Claude: ~/.claude/projects/<slug>/<sessionId>.jsonl
6
6
  * - Codex : ~/.codex/sessions/<sessionId>.jsonl
7
7
  * - Gemini: ~/.gemini/sessions/<sessionId>.jsonl
8
+ * - Qwen : ~/.qwen/projects/<sanitized>/chats/<sessionId>.jsonl
8
9
  *
9
10
  * Note:
10
11
  * - Usa FILESYSTEM come source of truth: non legge contenuti, solo metadati.
@@ -22,19 +23,21 @@ const HOME = process.env.HOME || '';
22
23
  const CLAUDE_PROJECTS = path.join(HOME, '.claude', 'projects');
23
24
  const CODEX_SESSIONS = path.join(HOME, '.codex', 'sessions');
24
25
  const GEMINI_SESSIONS = path.join(HOME, '.gemini', 'sessions');
26
+ const QWEN_PROJECTS = path.join(HOME, '.qwen', 'projects');
25
27
 
26
28
  class SessionImporter {
27
29
  constructor() {}
28
30
 
29
31
  /**
30
32
  * Importa tutte le sessioni per tutti gli engine
31
- * @returns {{claude:number, codex:number, gemini:number}}
33
+ * @returns {{claude:number, codex:number, gemini:number, qwen:number}}
32
34
  */
33
35
  importAll() {
34
36
  const claude = this.importClaudeSessions();
35
37
  const codex = this.importCodexSessions();
36
38
  const gemini = this.importGeminiSessions();
37
- return { claude, codex, gemini };
39
+ const qwen = this.importQwenSessions();
40
+ return { claude, codex, gemini, qwen };
38
41
  }
39
42
 
40
43
  /**
@@ -110,6 +113,36 @@ class SessionImporter {
110
113
  return imported;
111
114
  }
112
115
 
116
+ /**
117
+ * Qwen: ~/.qwen/projects/<sanitized>/chats/<sessionId>.jsonl
118
+ */
119
+ importQwenSessions() {
120
+ let imported = 0;
121
+ if (!fs.existsSync(QWEN_PROJECTS)) return imported;
122
+
123
+ const projects = fs.readdirSync(QWEN_PROJECTS);
124
+ for (const project of projects) {
125
+ const projectDir = path.join(QWEN_PROJECTS, project);
126
+ if (!fs.statSync(projectDir).isDirectory()) continue;
127
+
128
+ const chatsDir = path.join(projectDir, 'chats');
129
+ if (!fs.existsSync(chatsDir)) continue;
130
+
131
+ const files = fs.readdirSync(chatsDir).filter(f => f.endsWith('.jsonl'));
132
+ for (const file of files) {
133
+ const sessionId = file.replace('.jsonl', '');
134
+ if (this.sessionExists(sessionId)) continue;
135
+
136
+ this.insertSession(sessionId, 'qwen', '', null);
137
+ imported++;
138
+ }
139
+ }
140
+
141
+ if (imported > 0) saveDb();
142
+ console.log(`[SessionImporter] Qwen imported: ${imported}`);
143
+ return imported;
144
+ }
145
+
113
146
  /**
114
147
  * Inserisce riga minima in sessions
115
148
  */
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * SessionManager - Session Sync Pattern Implementation (TRI CLI v0.4.0)
3
3
  *
4
- * Simplified session management for Claude/Codex/Gemini engines.
4
+ * Simplified session management for Claude/Codex/Gemini/Qwen engines.
5
5
  * Principle: FILESYSTEM = SOURCE OF TRUTH
6
6
  *
7
7
  * Flow:
@@ -22,6 +22,7 @@ const SESSION_DIRS = {
22
22
  claude: path.join(process.env.HOME || '', '.claude', 'projects'),
23
23
  codex: path.join(process.env.HOME || '', '.codex', 'sessions'),
24
24
  gemini: path.join(process.env.HOME || '', '.gemini', 'sessions'),
25
+ qwen: path.join(process.env.HOME || '', '.qwen', 'projects'),
25
26
  };
26
27
 
27
28
  class SessionManager {
@@ -52,9 +53,9 @@ class SessionManager {
52
53
  sessionFileExists(sessionId, engine, workspacePath) {
53
54
  const normalizedEngine = this._normalizeEngine(engine);
54
55
 
55
- // Codex/Gemini exec mode doesn't create session files - trust DB mapping
56
+ // Codex/Gemini/Qwen exec mode doesn't require filesystem checks - trust DB mapping
56
57
  // Session continuity is managed via NexusCLI's message DB + contextBridge
57
- if (normalizedEngine === 'codex' || normalizedEngine === 'gemini') {
58
+ if (normalizedEngine === 'codex' || normalizedEngine === 'gemini' || normalizedEngine === 'qwen') {
58
59
  return true; // Always trust DB for exec-mode CLI sessions
59
60
  }
60
61
 
@@ -108,6 +109,11 @@ class SessionManager {
108
109
  case 'gemini':
109
110
  // Gemini sessions: ~/.gemini/sessions/<sessionId>.jsonl
110
111
  return path.join(SESSION_DIRS.gemini, `${sessionId}.jsonl`);
112
+ case 'qwen': {
113
+ // Qwen sessions: ~/.qwen/projects/<sanitized-cwd>/chats/<sessionId>.jsonl
114
+ const project = this._pathToQwenProject(workspacePath);
115
+ return path.join(SESSION_DIRS.qwen, project, 'chats', `${sessionId}.jsonl`);
116
+ }
111
117
 
112
118
  default:
113
119
  console.warn(`[SessionManager] Unknown engine: ${engine}`);
@@ -124,6 +130,7 @@ class SessionManager {
124
130
  if (lower.includes('claude')) return 'claude';
125
131
  if (lower.includes('codex') || lower.includes('openai')) return 'codex';
126
132
  if (lower.includes('gemini') || lower.includes('google')) return 'gemini';
133
+ if (lower.includes('qwen')) return 'qwen';
127
134
  return lower;
128
135
  }
129
136
 
@@ -138,6 +145,15 @@ class SessionManager {
138
145
  return workspacePath.replace(/\//g, '-');
139
146
  }
140
147
 
148
+ /**
149
+ * Convert workspace path to Qwen project dir (matches Qwen Storage.sanitizeCwd)
150
+ * Replaces non-alphanumeric characters with '-'
151
+ */
152
+ _pathToQwenProject(workspacePath) {
153
+ if (!workspacePath) return 'default';
154
+ return workspacePath.replace(/[^a-zA-Z0-9]/g, '-');
155
+ }
156
+
141
157
  /**
142
158
  * Generate workspace hash (legacy method, kept for compatibility)
143
159
  * @deprecated Use _pathToSlug instead (matches Claude Code behavior)
@@ -325,7 +341,12 @@ class SessionManager {
325
341
  this.lastAccess.delete(cacheKey);
326
342
 
327
343
  // Delete the original .jsonl file (SYNC DELETE)
328
- const sessionFile = this._getSessionFilePath(session.id, session.engine, session.workspace_path);
344
+ const sessionFile = this._getSessionFilePath(
345
+ session.id,
346
+ session.engine,
347
+ session.workspace_path,
348
+ session.session_path
349
+ );
329
350
  if (sessionFile && fs.existsSync(sessionFile)) {
330
351
  try {
331
352
  fs.unlinkSync(sessionFile);
@@ -353,10 +374,11 @@ class SessionManager {
353
374
  /**
354
375
  * Get the filesystem path for a session file
355
376
  */
356
- _getSessionFilePath(sessionId, engine, workspacePath) {
377
+ _getSessionFilePath(sessionId, engine, workspacePath, sessionPath) {
357
378
  const normalizedEngine = engine?.toLowerCase().includes('claude') ? 'claude'
358
379
  : engine?.toLowerCase().includes('codex') ? 'codex'
359
380
  : engine?.toLowerCase().includes('gemini') ? 'gemini'
381
+ : engine?.toLowerCase().includes('qwen') ? 'qwen'
360
382
  : 'claude';
361
383
 
362
384
  switch (normalizedEngine) {
@@ -367,6 +389,11 @@ class SessionManager {
367
389
  return path.join(SESSION_DIRS.codex, `${sessionId}.jsonl`);
368
390
  case 'gemini':
369
391
  return path.join(SESSION_DIRS.gemini, `${sessionId}.jsonl`);
392
+ case 'qwen': {
393
+ const project = this._pathToQwenProject(workspacePath);
394
+ const fileId = sessionPath || sessionId;
395
+ return path.join(SESSION_DIRS.qwen, project, 'chats', `${fileId}.jsonl`);
396
+ }
370
397
  default:
371
398
  return null;
372
399
  }