@yu_robotics/remote-cli 1.1.27 → 1.1.33

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 (43) hide show
  1. package/dist/client/MessageHandler.d.ts +23 -55
  2. package/dist/client/MessageHandler.d.ts.map +1 -1
  3. package/dist/client/MessageHandler.js +289 -265
  4. package/dist/client/MessageHandler.js.map +1 -1
  5. package/dist/commands/start.d.ts.map +1 -1
  6. package/dist/commands/start.js +22 -11
  7. package/dist/commands/start.js.map +1 -1
  8. package/dist/executor/ClaudePersistentExecutor.d.ts +1 -1
  9. package/dist/executor/ClaudePersistentExecutor.d.ts.map +1 -1
  10. package/dist/executor/ClaudePersistentExecutor.js +17 -6
  11. package/dist/executor/ClaudePersistentExecutor.js.map +1 -1
  12. package/dist/executor/GeminiExecutor.d.ts +6 -0
  13. package/dist/executor/GeminiExecutor.d.ts.map +1 -1
  14. package/dist/executor/GeminiExecutor.js +23 -1
  15. package/dist/executor/GeminiExecutor.js.map +1 -1
  16. package/dist/executor/acp/SessionManager.d.ts +8 -0
  17. package/dist/executor/acp/SessionManager.d.ts.map +1 -1
  18. package/dist/executor/acp/SessionManager.js +18 -0
  19. package/dist/executor/acp/SessionManager.js.map +1 -1
  20. package/dist/executor/index.d.ts +1 -1
  21. package/dist/executor/index.d.ts.map +1 -1
  22. package/dist/executor/index.js +3 -3
  23. package/dist/executor/index.js.map +1 -1
  24. package/dist/thread/ThreadExecutorPool.d.ts +57 -0
  25. package/dist/thread/ThreadExecutorPool.d.ts.map +1 -0
  26. package/dist/thread/ThreadExecutorPool.js +104 -0
  27. package/dist/thread/ThreadExecutorPool.js.map +1 -0
  28. package/dist/thread/ThreadManager.d.ts +65 -0
  29. package/dist/thread/ThreadManager.d.ts.map +1 -0
  30. package/dist/thread/ThreadManager.js +182 -0
  31. package/dist/thread/ThreadManager.js.map +1 -0
  32. package/dist/thread/index.d.ts +6 -0
  33. package/dist/thread/index.d.ts.map +1 -0
  34. package/dist/thread/index.js +12 -0
  35. package/dist/thread/index.js.map +1 -0
  36. package/dist/thread/types.d.ts +28 -0
  37. package/dist/thread/types.d.ts.map +1 -0
  38. package/dist/thread/types.js +11 -0
  39. package/dist/thread/types.js.map +1 -0
  40. package/dist/types/index.d.ts +12 -1
  41. package/dist/types/index.d.ts.map +1 -1
  42. package/dist/types/index.js.map +1 -1
  43. package/package.json +3 -3
@@ -1,50 +1,45 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MessageHandler = void 0;
4
- const executor_1 = require("../executor");
5
4
  const hooks_1 = require("../hooks");
6
5
  const FileReadDetector_1 = require("../utils/FileReadDetector");
7
6
  const child_process_1 = require("child_process");
8
7
  /**
9
- * Message Handler
10
- * Responsible for handling messages from WebSocket and invoking Claude executor
8
+ * Thread-aware Message Handler.
9
+ * Each thread has its own executor process managed by ThreadExecutorPool.
10
+ * Commands without a threadId are routed to the default thread.
11
11
  */
12
12
  class MessageHandler {
13
13
  wsClient;
14
- executor;
14
+ threadPool;
15
+ threadManager;
15
16
  directoryGuard;
16
17
  config;
17
18
  isDestroyed = false;
18
- isExecuting = false;
19
19
  currentOpenId;
20
20
  notificationAdapter;
21
- constructor(wsClient, executor, directoryGuard, config) {
21
+ constructor(wsClient, threadPool, threadManager, directoryGuard, config) {
22
22
  this.wsClient = wsClient;
23
- this.executor = executor;
23
+ this.threadPool = threadPool;
24
+ this.threadManager = threadManager;
24
25
  this.directoryGuard = directoryGuard;
25
26
  this.config = config;
26
- // Initialize Feishu notification adapter
27
27
  this.notificationAdapter = new hooks_1.FeishuNotificationAdapter(wsClient);
28
28
  this.notificationAdapter.register();
29
29
  }
30
30
  /**
31
31
  * Handle message (supports new IncomingMessage format)
32
- * @param message Message object
33
32
  */
34
33
  async handleMessage(message) {
35
- // Check if already destroyed
36
- if (this.isDestroyed) {
34
+ if (this.isDestroyed)
37
35
  return;
38
- }
39
- // Validate message structure
40
36
  if (!message || !this.isValidMessage(message)) {
41
- this.sendResponse(message?.messageId || 'unknown', {
37
+ this.sendResponse(message?.messageId || 'unknown', undefined, {
42
38
  success: false,
43
39
  error: 'Invalid message format',
44
40
  });
45
41
  return;
46
42
  }
47
- // Handle different types of messages
48
43
  switch (message.type) {
49
44
  case 'status':
50
45
  await this.handleStatusQuery(message.messageId);
@@ -53,53 +48,56 @@ class MessageHandler {
53
48
  await this.handleCommandMessage(message);
54
49
  return;
55
50
  case 'heartbeat':
56
- // Silently ignore heartbeat responses from server
57
51
  return;
58
52
  case 'binding_confirm':
59
- // Silently ignore binding confirmation from server
60
53
  return;
61
54
  default:
62
- this.sendResponse(message.messageId, {
55
+ this.sendResponse(message.messageId, undefined, {
63
56
  success: false,
64
57
  error: `Unknown message type: ${message.type}`,
65
58
  });
66
59
  }
67
60
  }
68
61
  /**
69
- * Handle command message
62
+ * Handle command message — route to the correct thread executor.
70
63
  */
71
64
  async handleCommandMessage(message) {
72
- const { messageId, content, workingDirectory, openId, isSlashCommand } = message;
73
- // Store openId for response routing and notifications
65
+ const { messageId, content, workingDirectory, openId, isSlashCommand, threadId } = message;
74
66
  this.currentOpenId = openId;
75
67
  this.notificationAdapter.setCurrentOpenId(openId);
76
- // Handle /abort command first, even when busy
68
+ // Resolve target thread fall back to default if not specified
69
+ const thread = threadId
70
+ ? this.threadManager.getThread(threadId)
71
+ : this.threadManager.getDefaultThread();
72
+ if (!thread) {
73
+ this.sendResponse(messageId, undefined, {
74
+ success: false,
75
+ error: `Thread not found: ${threadId}`,
76
+ });
77
+ return;
78
+ }
79
+ const resolvedThreadId = thread.id;
80
+ const executor = this.threadPool.getExecutor(resolvedThreadId);
81
+ // Handle /abort for this specific thread (bypasses busy check)
77
82
  if (content?.trim() === '/abort') {
78
- await this.handleAbortCommand(messageId);
83
+ await this.handleAbortCommand(messageId, resolvedThreadId, executor);
79
84
  return;
80
85
  }
81
86
  // Check if executor is waiting for interactive input
82
- if ('isWaitingInput' in this.executor && typeof this.executor.isWaitingInput === 'function') {
83
- const executor = this.executor;
84
- if (executor.isWaitingInput()) {
87
+ if ('isWaitingInput' in executor && typeof executor.isWaitingInput === 'function') {
88
+ const ex = executor;
89
+ if (ex.isWaitingInput()) {
85
90
  const input = content?.trim();
86
91
  if (input) {
87
- const sent = executor.sendInput(input);
88
- if (sent) {
89
- this.sendResponse(messageId, {
90
- success: true,
91
- output: `✅ Sent: "${input}"`,
92
- });
93
- }
94
- else {
95
- this.sendResponse(messageId, {
96
- success: false,
97
- error: '❌ Failed to send input - executor is no longer waiting',
98
- });
99
- }
92
+ const sent = ex.sendInput(input);
93
+ this.sendResponse(messageId, resolvedThreadId, {
94
+ success: sent,
95
+ output: sent ? `✅ Sent: "${input}"` : undefined,
96
+ error: sent ? undefined : '❌ Failed to send input - executor is no longer waiting',
97
+ });
100
98
  }
101
99
  else {
102
- this.sendResponse(messageId, {
100
+ this.sendResponse(messageId, resolvedThreadId, {
103
101
  success: false,
104
102
  error: '❌ Please provide a non-empty input',
105
103
  });
@@ -107,19 +105,18 @@ class MessageHandler {
107
105
  return;
108
106
  }
109
107
  }
110
- // Check if there is a task currently executing
111
- if (this.isExecuting) {
112
- this.sendResponse(messageId, {
108
+ // Per-thread busy check
109
+ if (this.threadPool.isThreadBusy(resolvedThreadId)) {
110
+ this.sendResponse(messageId, resolvedThreadId, {
113
111
  success: false,
114
- error: 'Executor is busy, please wait for current task to complete. Send the abort command to cancel the running task.',
112
+ error: `Thread "${thread.name}" is busy. Send /abort to cancel the running task, or use another thread.`,
115
113
  });
116
114
  return;
117
115
  }
118
- // If working directory is provided, validate and set it
116
+ // Validate and set working directory if provided
119
117
  if (workingDirectory) {
120
- // Verify directory is in the whitelist
121
118
  if (!this.directoryGuard.isSafePath(workingDirectory)) {
122
- this.sendResponse(messageId, {
119
+ this.sendResponse(messageId, resolvedThreadId, {
123
120
  success: false,
124
121
  error: `Directory not in whitelist: ${workingDirectory}\n\nAllowed directories:\n${this.directoryGuard
125
122
  .getAllowedDirectories()
@@ -128,49 +125,44 @@ class MessageHandler {
128
125
  });
129
126
  return;
130
127
  }
131
- // Set working directory
132
- await this.executor.setWorkingDirectory(workingDirectory);
128
+ await executor.setWorkingDirectory(workingDirectory);
133
129
  }
134
130
  try {
135
- this.isExecuting = true;
136
- // Handle built-in commands (except /abort which was handled above)
137
- const builtInResult = await this.handleBuiltInCommand(messageId, content);
138
- if (builtInResult) {
131
+ this.threadPool.setThreadBusy(resolvedThreadId, true);
132
+ // Update thread activity timestamp
133
+ await this.threadManager.updateThread(resolvedThreadId, { lastActiveAt: Date.now() });
134
+ const builtInResult = await this.handleBuiltInCommand(messageId, resolvedThreadId, content, executor);
135
+ if (builtInResult)
139
136
  return;
140
- }
141
- // Check if this is a passthrough slash command from server
142
137
  if (isSlashCommand) {
143
138
  console.log(`[MessageHandler] Executing passthrough slash command: ${content}`);
144
- await this.executeSlashCommand(messageId, content);
139
+ await this.executeSlashCommand(messageId, resolvedThreadId, content, executor);
145
140
  return;
146
141
  }
147
- // Expand command shortcuts
148
142
  const expandedContent = this.expandCommandShortcuts(content);
149
- // Detect file-reading intent and inject hint for mobile optimization
150
143
  const processedContent = (0, FileReadDetector_1.processFileReadContent)(expandedContent);
151
- // Execute Claude command
152
- await this.executeCommand(messageId, processedContent);
144
+ await this.executeCommand(messageId, resolvedThreadId, processedContent, executor);
153
145
  }
154
146
  catch (error) {
155
- this.sendResponse(messageId, {
147
+ this.threadPool.setThreadError(resolvedThreadId, true);
148
+ this.sendResponse(messageId, resolvedThreadId, {
156
149
  success: false,
157
150
  error: error instanceof Error ? error.message : 'Unknown error',
158
151
  });
159
152
  }
160
153
  finally {
161
- this.isExecuting = false;
154
+ this.threadPool.setThreadBusy(resolvedThreadId, false);
162
155
  }
163
156
  }
164
157
  /**
165
- * Handle abort command
166
- * Can be executed even when executor is busy
158
+ * Handle /abort command for a specific thread executor
167
159
  */
168
- async handleAbortCommand(messageId) {
169
- const wasExecuting = this.isExecuting;
170
- const aborted = await this.executor.abort();
160
+ async handleAbortCommand(messageId, threadId, executor) {
161
+ const wasExecuting = this.threadPool.isThreadBusy(threadId);
162
+ const aborted = await executor.abort();
171
163
  if (aborted) {
172
- this.isExecuting = false;
173
- this.sendResponse(messageId, {
164
+ this.threadPool.setThreadBusy(threadId, false);
165
+ this.sendResponse(messageId, threadId, {
174
166
  success: true,
175
167
  output: wasExecuting
176
168
  ? '✅ Current command has been aborted'
@@ -178,7 +170,7 @@ class MessageHandler {
178
170
  });
179
171
  }
180
172
  else {
181
- this.sendResponse(messageId, {
173
+ this.sendResponse(messageId, threadId, {
182
174
  success: true,
183
175
  output: 'ℹ️ No command is currently executing',
184
176
  });
@@ -188,207 +180,280 @@ class MessageHandler {
188
180
  * Handle status query
189
181
  */
190
182
  async handleStatusQuery(messageId) {
183
+ const defaultThread = this.threadManager.getDefaultThread();
184
+ const defaultExecutor = this.threadPool.getExecutor(defaultThread.id);
191
185
  this.wsClient.send({
192
186
  type: 'status',
193
187
  messageId,
194
188
  status: {
195
189
  connected: this.wsClient.isConnected(),
196
190
  allowedDirectories: this.directoryGuard.getAllowedDirectories(),
197
- currentWorkingDirectory: this.executor.getCurrentWorkingDirectory(),
191
+ currentWorkingDirectory: defaultExecutor.getCurrentWorkingDirectory(),
192
+ threads: this.threadPool.getSummaries(),
198
193
  },
199
194
  timestamp: Date.now(),
200
195
  });
201
196
  }
202
- /**
203
- * Validate message structure
204
- */
205
197
  isValidMessage(message) {
206
- if (!message || typeof message !== 'object') {
198
+ if (!message || typeof message !== 'object')
207
199
  return false;
208
- }
209
- if (message.type !== 'command') {
210
- return true; // Non-command messages don't need further validation
211
- }
200
+ if (message.type !== 'command')
201
+ return true;
212
202
  return Boolean(message.messageId && message.content);
213
203
  }
214
204
  /**
215
- * Handle built-in commands
216
- * @returns Returns true if built-in command was handled, otherwise false
205
+ * Handle built-in commands (thread-scoped).
206
+ * Returns true if handled.
217
207
  */
218
- async handleBuiltInCommand(messageId, content) {
208
+ async handleBuiltInCommand(messageId, threadId, content, executor) {
219
209
  const trimmed = content.trim();
220
- // /status command
221
210
  if (trimmed === '/status') {
222
- const cwd = this.executor.getCurrentWorkingDirectory();
211
+ const cwd = executor.getCurrentWorkingDirectory();
223
212
  const allowedDirs = this.directoryGuard.getAllowedDirectories();
224
- this.sendResponse(messageId, {
213
+ const threads = this.threadPool.getSummaries();
214
+ const threadList = threads
215
+ .map(t => ` • ${t.name}${t.status === 'running' ? ' 🔄' : t.status === 'error' ? ' ❌' : ' ✅'} (${t.status})`)
216
+ .join('\n');
217
+ this.sendResponse(messageId, threadId, {
225
218
  success: true,
226
219
  output: `📊 Status:
227
220
  - Working Directory: ${cwd}
228
221
  - Allowed Directories: ${allowedDirs.join(', ')}
229
- - Connection: Active`,
222
+ - Connection: Active
223
+ - Threads:\n${threadList}`,
230
224
  });
231
225
  return true;
232
226
  }
233
- // /help command
234
227
  if (trimmed === '/help') {
235
- this.sendResponse(messageId, {
228
+ this.sendResponse(messageId, threadId, {
236
229
  success: true,
237
230
  output: `📖 Available commands:
238
231
  - /help - Show this help message
239
- - /status - Show current status
240
- - /abort - Abort the currently executing command
241
- - /clear - Clear conversation context
232
+ - /status - Show current status and threads
233
+ - /abort - Abort the currently executing command in this thread
234
+ - /clear - Clear conversation context for this thread
242
235
  - /compact - Compress conversation history to reduce context size
243
- - /cd <directory> - Change working directory
236
+ - /cd <directory> - Change working directory for this thread
244
237
  - /backend - List available AI backends and switch between them
238
+ - /thread list - List all threads with their status
239
+ - /thread new [name] - Create a new thread
240
+ - /thread delete <name> - Delete a thread (only when idle)
245
241
  You can also use natural language commands to control Claude Code CLI.`,
246
242
  });
247
243
  return true;
248
244
  }
249
- // /clear command
250
245
  if (trimmed === '/clear') {
251
- this.executor.resetContext();
252
- this.sendResponse(messageId, {
246
+ executor.resetContext();
247
+ this.sendResponse(messageId, threadId, {
253
248
  success: true,
254
249
  output: '✅ Conversation context cleared',
255
250
  });
256
251
  return true;
257
252
  }
258
- // /compact command - compress conversation history via Claude CLI's built-in /compact
259
253
  if (trimmed === '/compact') {
260
- if (!('compactWhenFull' in this.executor && typeof this.executor.compactWhenFull === 'function')) {
261
- this.sendResponse(messageId, {
254
+ if (!('compactWhenFull' in executor && typeof executor.compactWhenFull === 'function')) {
255
+ this.sendResponse(messageId, threadId, {
262
256
  success: false,
263
257
  error: '/compact is not supported in this executor mode',
264
258
  });
265
259
  return true;
266
260
  }
267
- this.sendStreamChunk(messageId, '🗜️ Compressing conversation history...\n');
268
- const persistentExecutor = this.executor;
269
- const result = await persistentExecutor.compactWhenFull((chunk) => {
270
- this.sendStreamChunk(messageId, chunk);
261
+ this.sendStreamChunk(messageId, threadId, '🗜️ Compressing conversation history...\n');
262
+ const result = await executor.compactWhenFull((chunk) => {
263
+ this.sendStreamChunk(messageId, threadId, chunk);
271
264
  });
272
- if (!result.success) {
273
- this.sendResponse(messageId, {
274
- success: false,
275
- error: result.error || 'Compaction failed',
276
- });
277
- }
278
- else {
279
- this.sendResponse(messageId, {
280
- success: true,
281
- output: '✅ Conversation history compressed',
282
- });
283
- }
265
+ this.sendResponse(messageId, threadId, result.success
266
+ ? { success: true, output: '✅ Conversation history compressed' }
267
+ : { success: false, error: result.error || 'Compaction failed' });
284
268
  return true;
285
269
  }
286
- // /backend command
287
270
  if (trimmed === '/backend' || trimmed.startsWith('/backend ')) {
288
- await this.handleBackendCommand(messageId, trimmed);
271
+ await this.handleBackendCommand(messageId, threadId, trimmed);
289
272
  return true;
290
273
  }
291
- // /cd command
292
274
  if (trimmed.startsWith('/cd')) {
293
275
  const parts = trimmed.split(/\s+/);
294
276
  if (parts.length < 2) {
295
- this.sendResponse(messageId, {
296
- success: false,
297
- error: 'Usage: /cd <directory>',
298
- });
277
+ this.sendResponse(messageId, threadId, { success: false, error: 'Usage: /cd <directory>' });
299
278
  return true;
300
279
  }
301
280
  const targetDir = parts.slice(1).join(' ');
302
281
  try {
303
- await this.executor.setWorkingDirectory(targetDir);
304
- const newCwd = this.executor.getCurrentWorkingDirectory();
305
- // Save lastWorkingDirectory to config (set() already saves)
306
- await this.config.set('lastWorkingDirectory', newCwd);
307
- this.sendResponse(messageId, {
282
+ await executor.setWorkingDirectory(targetDir);
283
+ const newCwd = executor.getCurrentWorkingDirectory();
284
+ // Persist the thread's working directory (restored on next startup via ThreadExecutorPool)
285
+ await this.threadManager.updateThread(threadId, { workingDirectory: newCwd });
286
+ this.sendResponse(messageId, threadId, {
308
287
  success: true,
309
288
  output: `✅ Changed working directory to: ${newCwd}`,
310
289
  });
311
290
  }
312
291
  catch (error) {
313
- this.sendResponse(messageId, {
292
+ this.sendResponse(messageId, threadId, {
314
293
  success: false,
315
- error: error instanceof Error
316
- ? error.message
317
- : 'Failed to change directory',
294
+ error: error instanceof Error ? error.message : 'Failed to change directory',
318
295
  });
319
296
  }
320
297
  return true;
321
298
  }
299
+ if (trimmed === '/thread' || trimmed.startsWith('/thread ')) {
300
+ await this.handleThreadCommand(messageId, threadId, trimmed);
301
+ return true;
302
+ }
322
303
  return false;
323
304
  }
305
+ /**
306
+ * Handle /thread subcommands
307
+ */
308
+ async handleThreadCommand(messageId, callerThreadId, trimmed) {
309
+ const parts = trimmed.split(/\s+/);
310
+ const sub = parts[1]; // list | new | delete
311
+ if (!sub || sub === 'list') {
312
+ const summaries = this.threadPool.getSummaries();
313
+ const lines = summaries.map(t => {
314
+ const icon = t.status === 'running' ? '🔄' : t.status === 'error' ? '❌' : '✅';
315
+ const current = t.id === callerThreadId ? ' ← (this thread)' : '';
316
+ return `${icon} ${t.name}${current}`;
317
+ });
318
+ this.sendResponse(messageId, callerThreadId, {
319
+ success: true,
320
+ output: `🧵 Threads:\n${lines.join('\n')}\n\nUse /thread new [name] to create a new thread.\nReply to a thread's card to send commands to it.`,
321
+ });
322
+ return;
323
+ }
324
+ if (sub === 'new') {
325
+ const name = parts[2];
326
+ const callerThread = this.threadManager.getThread(callerThreadId) || this.threadManager.getDefaultThread();
327
+ const callerExecutor = this.threadPool.getExecutor(callerThread.id);
328
+ const cwd = callerExecutor.getCurrentWorkingDirectory();
329
+ try {
330
+ const newThread = await this.threadManager.createThread(name || this.generateThreadName(), cwd);
331
+ // Use newThread.id so the router maps this card to the new thread,
332
+ // enabling the user to reply to this card to target the new thread.
333
+ this.sendResponse(messageId, newThread.id, {
334
+ success: true,
335
+ output: `✅ Thread "${newThread.name}" created.\nReply to this card to send the first command to the new thread.`,
336
+ threads: this.threadPool.getSummaries(),
337
+ });
338
+ }
339
+ catch (error) {
340
+ this.sendResponse(messageId, callerThreadId, {
341
+ success: false,
342
+ error: error instanceof Error ? error.message : 'Failed to create thread',
343
+ });
344
+ }
345
+ return;
346
+ }
347
+ if (sub === 'delete') {
348
+ const name = parts[2];
349
+ if (!name) {
350
+ this.sendResponse(messageId, callerThreadId, {
351
+ success: false,
352
+ error: 'Usage: /thread delete <name>',
353
+ });
354
+ return;
355
+ }
356
+ const target = this.threadManager.getThreadByName(name);
357
+ if (!target) {
358
+ this.sendResponse(messageId, callerThreadId, {
359
+ success: false,
360
+ error: `Thread "${name}" not found.`,
361
+ });
362
+ return;
363
+ }
364
+ if (this.threadPool.isThreadBusy(target.id)) {
365
+ this.sendResponse(messageId, callerThreadId, {
366
+ success: false,
367
+ error: `Cannot delete thread "${name}" while it is running. Send /abort first.`,
368
+ });
369
+ return;
370
+ }
371
+ try {
372
+ await this.threadPool.destroyThread(target.id);
373
+ await this.threadManager.deleteThread(target.id);
374
+ this.sendResponse(messageId, callerThreadId, {
375
+ success: true,
376
+ output: `✅ Thread "${name}" deleted.`,
377
+ threads: this.threadPool.getSummaries(),
378
+ });
379
+ }
380
+ catch (error) {
381
+ this.sendResponse(messageId, callerThreadId, {
382
+ success: false,
383
+ error: error instanceof Error ? error.message : 'Failed to delete thread',
384
+ });
385
+ }
386
+ return;
387
+ }
388
+ this.sendResponse(messageId, callerThreadId, {
389
+ success: false,
390
+ error: `Unknown /thread subcommand: ${sub}\nUsage: /thread list | /thread new [name] | /thread delete <name>`,
391
+ });
392
+ }
393
+ /**
394
+ * Generate a unique auto-name for a new thread (e.g. "thread-2")
395
+ */
396
+ generateThreadName() {
397
+ const existing = new Set(this.threadManager.listThreads().map(t => t.name));
398
+ for (let i = 2; i <= 99; i++) {
399
+ const name = `thread-${i}`;
400
+ if (!existing.has(name))
401
+ return name;
402
+ }
403
+ return `thread-${Date.now()}`;
404
+ }
324
405
  /**
325
406
  * Expand command shortcuts
326
407
  */
327
408
  expandCommandShortcuts(content) {
328
409
  const trimmed = content.trim();
329
- // Only expand when command is the entire content
330
- if (trimmed === '/r' || trimmed === '/resume') {
410
+ if (trimmed === '/r' || trimmed === '/resume')
331
411
  return 'Please resume the previous conversation';
332
- }
333
- if (trimmed === '/c' || trimmed === '/continue') {
412
+ if (trimmed === '/c' || trimmed === '/continue')
334
413
  return 'Please continue from where we left off';
335
- }
336
414
  return content;
337
415
  }
338
416
  /**
339
417
  * Execute passthrough slash command using local Claude CLI
340
- * This allows users to use their custom slash commands
341
418
  */
342
- async executeSlashCommand(messageId, command) {
419
+ async executeSlashCommand(messageId, threadId, command, executor) {
343
420
  return new Promise((resolve) => {
344
421
  const chunks = [];
345
422
  const errorChunks = [];
346
423
  console.log(`[MessageHandler] Spawning Claude CLI for command: ${command}`);
347
- // Spawn Claude CLI with the slash command
348
- // Use --print to get output and exit
349
424
  const child = (0, child_process_1.spawn)('claude', [command, '--print'], {
350
- cwd: this.executor.getCurrentWorkingDirectory(),
425
+ cwd: executor.getCurrentWorkingDirectory(),
351
426
  stdio: ['ignore', 'pipe', 'pipe'],
352
- env: {
353
- ...process.env,
354
- CLAUDECODE: '', // Prevent nested session error
355
- },
427
+ env: { ...process.env, CLAUDECODE: '' },
356
428
  });
357
- // Handle stdout (stream chunks)
358
429
  child.stdout?.on('data', (data) => {
359
430
  const chunk = data.toString();
360
431
  chunks.push(chunk);
361
- this.sendStreamChunk(messageId, chunk);
432
+ this.sendStreamChunk(messageId, threadId, chunk);
362
433
  });
363
- // Handle stderr
364
434
  child.stderr?.on('data', (data) => {
365
- const chunk = data.toString();
366
- errorChunks.push(chunk);
367
- console.error(`[MessageHandler] Claude stderr: ${chunk}`);
435
+ errorChunks.push(data.toString());
368
436
  });
369
- // Handle process exit
370
437
  child.on('exit', (code) => {
371
- console.log(`[MessageHandler] Claude process exited with code: ${code}`);
372
438
  if (code === 0) {
373
439
  const output = chunks.join('');
374
- this.sendResponse(messageId, {
440
+ this.sendResponse(messageId, threadId, {
375
441
  success: true,
376
442
  output: output.trim() || '✅ Command executed successfully',
377
443
  });
378
444
  }
379
445
  else {
380
446
  const errorOutput = errorChunks.join('') || chunks.join('');
381
- this.sendResponse(messageId, {
447
+ this.sendResponse(messageId, threadId, {
382
448
  success: false,
383
449
  error: errorOutput.trim() || `Command failed with exit code ${code}`,
384
450
  });
385
451
  }
386
452
  resolve();
387
453
  });
388
- // Handle process error
389
454
  child.on('error', (error) => {
390
- console.error(`[MessageHandler] Failed to spawn Claude:`, error);
391
- this.sendResponse(messageId, {
455
+ console.error('[MessageHandler] Failed to spawn Claude:', error);
456
+ this.sendResponse(messageId, threadId, {
392
457
  success: false,
393
458
  error: `Failed to execute command: ${error.message}`,
394
459
  });
@@ -397,80 +462,58 @@ You can also use natural language commands to control Claude Code CLI.`,
397
462
  });
398
463
  }
399
464
  /**
400
- * Execute Claude command
465
+ * Execute AI command on a specific executor
401
466
  */
402
- async executeCommand(messageId, content) {
467
+ async executeCommand(messageId, threadId, content, executor) {
403
468
  try {
404
- const result = await this.executor.execute(content, {
405
- onStream: (chunk) => {
406
- this.sendStreamChunk(messageId, chunk);
407
- },
408
- onToolUse: (toolUse) => {
409
- this.sendToolUse(messageId, toolUse);
410
- },
411
- onToolResult: (toolResult) => {
412
- this.sendToolResult(messageId, toolResult);
413
- },
414
- onRedactedThinking: () => {
415
- this.sendRedactedThinking(messageId);
416
- },
417
- onPlanMode: (planContent) => {
418
- this.sendPlanMode(messageId, planContent);
419
- },
469
+ const result = await executor.execute(content, {
470
+ onStream: (chunk) => this.sendStreamChunk(messageId, threadId, chunk),
471
+ onToolUse: (toolUse) => this.sendToolUse(messageId, threadId, toolUse),
472
+ onToolResult: (toolResult) => this.sendToolResult(messageId, threadId, toolResult),
473
+ onRedactedThinking: () => this.sendRedactedThinking(messageId, threadId),
474
+ onPlanMode: (planContent) => this.sendPlanMode(messageId, threadId, planContent),
420
475
  });
421
- // Only send success status, not the output
422
- // Output has already been streamed via onStream callback
423
476
  if (!result.success && result.error && result.error.includes('Prompt too long')) {
424
- if ('compactWhenFull' in this.executor && typeof this.executor.compactWhenFull === 'function') {
425
- // Context is full - use external compact which stops/restarts the process
426
- this.sendStreamChunk(messageId, '⚠️ Conversation history too long, auto-compressing...\n');
427
- const persistentExecutor = this.executor;
428
- const compactResult = await persistentExecutor.compactWhenFull((chunk) => {
429
- this.sendStreamChunk(messageId, chunk);
477
+ if ('compactWhenFull' in executor && typeof executor.compactWhenFull === 'function') {
478
+ this.sendStreamChunk(messageId, threadId, '⚠️ Conversation history too long, auto-compressing...\n');
479
+ const compactResult = await executor.compactWhenFull((chunk) => {
480
+ this.sendStreamChunk(messageId, threadId, chunk);
430
481
  });
431
482
  if (!compactResult.success) {
432
- this.sendResponse(messageId, {
483
+ this.sendResponse(messageId, threadId, {
433
484
  success: false,
434
485
  error: `❌ Auto-compact failed: ${compactResult.error}\n\nUse /compact to try again, or /clear to start fresh.`,
435
486
  });
436
487
  return;
437
488
  }
438
- this.sendStreamChunk(messageId, '✅ Compressed. Retrying...\n');
439
- const retryResult = await this.executor.execute(content, {
440
- onStream: (chunk) => { this.sendStreamChunk(messageId, chunk); },
441
- onToolUse: (toolUse) => { this.sendToolUse(messageId, toolUse); },
442
- onToolResult: (toolResult) => { this.sendToolResult(messageId, toolResult); },
443
- onRedactedThinking: () => { this.sendRedactedThinking(messageId); },
444
- onPlanMode: (planContent) => { this.sendPlanMode(messageId, planContent); },
445
- });
446
- this.sendResponse(messageId, {
447
- success: retryResult.success,
448
- error: retryResult.error,
489
+ this.sendStreamChunk(messageId, threadId, '✅ Compressed. Retrying...\n');
490
+ const retryResult = await executor.execute(content, {
491
+ onStream: (chunk) => this.sendStreamChunk(messageId, threadId, chunk),
492
+ onToolUse: (toolUse) => this.sendToolUse(messageId, threadId, toolUse),
493
+ onToolResult: (toolResult) => this.sendToolResult(messageId, threadId, toolResult),
494
+ onRedactedThinking: () => this.sendRedactedThinking(messageId, threadId),
495
+ onPlanMode: (planContent) => this.sendPlanMode(messageId, threadId, planContent),
449
496
  });
497
+ this.sendResponse(messageId, threadId, { success: retryResult.success, error: retryResult.error, threads: this.threadPool.getSummaries() });
450
498
  return;
451
499
  }
452
- this.sendResponse(messageId, {
500
+ this.sendResponse(messageId, threadId, {
453
501
  success: false,
454
502
  error: '❌ Conversation history too long.\n\nUse /compact to compress it, or /clear to start fresh.',
455
503
  });
456
504
  return;
457
505
  }
458
- this.sendResponse(messageId, {
459
- success: result.success,
460
- error: result.error,
461
- });
506
+ this.sendResponse(messageId, threadId, { success: result.success, error: result.error, threads: this.threadPool.getSummaries() });
462
507
  }
463
508
  catch (error) {
464
- this.sendResponse(messageId, {
509
+ this.sendResponse(messageId, threadId, {
465
510
  success: false,
466
511
  error: error instanceof Error ? error.message : 'Execution error',
467
512
  });
468
513
  }
469
514
  }
470
- /**
471
- * Send streaming output chunk
472
- */
473
- sendStreamChunk(messageId, chunk) {
515
+ // ── Outgoing message helpers ──────────────────────────────────────────────
516
+ sendStreamChunk(messageId, threadId, chunk) {
474
517
  try {
475
518
  this.wsClient.send({
476
519
  type: 'stream',
@@ -478,18 +521,15 @@ You can also use natural language commands to control Claude Code CLI.`,
478
521
  chunk,
479
522
  streamType: 'text',
480
523
  openId: this.currentOpenId,
524
+ threadId,
481
525
  timestamp: Date.now(),
482
526
  });
483
527
  }
484
528
  catch (error) {
485
- // Ignore send errors, don't affect main flow
486
529
  console.error('Failed to send stream chunk:', error);
487
530
  }
488
531
  }
489
- /**
490
- * Send tool use event
491
- */
492
- sendToolUse(messageId, toolUse) {
532
+ sendToolUse(messageId, threadId, toolUse) {
493
533
  try {
494
534
  this.wsClient.send({
495
535
  type: 'stream',
@@ -497,6 +537,7 @@ You can also use natural language commands to control Claude Code CLI.`,
497
537
  streamType: 'tool_use',
498
538
  toolUse,
499
539
  openId: this.currentOpenId,
540
+ threadId,
500
541
  timestamp: Date.now(),
501
542
  });
502
543
  }
@@ -504,10 +545,7 @@ You can also use natural language commands to control Claude Code CLI.`,
504
545
  console.error('Failed to send tool use:', error);
505
546
  }
506
547
  }
507
- /**
508
- * Send tool result event
509
- */
510
- sendToolResult(messageId, toolResult) {
548
+ sendToolResult(messageId, threadId, toolResult) {
511
549
  try {
512
550
  this.wsClient.send({
513
551
  type: 'stream',
@@ -515,6 +553,7 @@ You can also use natural language commands to control Claude Code CLI.`,
515
553
  streamType: 'tool_result',
516
554
  toolResult,
517
555
  openId: this.currentOpenId,
556
+ threadId,
518
557
  timestamp: Date.now(),
519
558
  });
520
559
  }
@@ -522,17 +561,14 @@ You can also use natural language commands to control Claude Code CLI.`,
522
561
  console.error('Failed to send tool result:', error);
523
562
  }
524
563
  }
525
- /**
526
- * Send redacted thinking event
527
- * This occurs when AI reasoning is filtered by safety systems (Claude 3.7 Sonnet, Gemini)
528
- */
529
- sendRedactedThinking(messageId) {
564
+ sendRedactedThinking(messageId, threadId) {
530
565
  try {
531
566
  this.wsClient.send({
532
567
  type: 'stream',
533
568
  messageId,
534
569
  streamType: 'redacted_thinking',
535
570
  openId: this.currentOpenId,
571
+ threadId,
536
572
  timestamp: Date.now(),
537
573
  });
538
574
  }
@@ -540,12 +576,7 @@ You can also use natural language commands to control Claude Code CLI.`,
540
576
  console.error('Failed to send redacted thinking:', error);
541
577
  }
542
578
  }
543
- /**
544
- * Send plan mode event
545
- * Fired when Claude completes its plan between EnterPlanMode and ExitPlanMode tool calls.
546
- * Execution is auto-approved; this event is for user visibility only.
547
- */
548
- sendPlanMode(messageId, planContent) {
579
+ sendPlanMode(messageId, threadId, planContent) {
549
580
  try {
550
581
  this.wsClient.send({
551
582
  type: 'stream',
@@ -553,6 +584,7 @@ You can also use natural language commands to control Claude Code CLI.`,
553
584
  streamType: 'plan_mode',
554
585
  planContent,
555
586
  openId: this.currentOpenId,
587
+ threadId,
556
588
  timestamp: Date.now(),
557
589
  });
558
590
  }
@@ -560,29 +592,33 @@ You can also use natural language commands to control Claude Code CLI.`,
560
592
  console.error('Failed to send plan mode:', error);
561
593
  }
562
594
  }
563
- /**
564
- * Send structured content for rich formatting
565
- */
566
- sendStructuredContent(messageId, structuredContent) {
595
+ sendStructuredContent(messageId, threadId, structuredContent) {
567
596
  try {
568
597
  this.wsClient.send({
569
598
  type: 'structured',
570
599
  messageId,
571
600
  structuredContent,
572
601
  openId: this.currentOpenId,
602
+ threadId,
573
603
  timestamp: Date.now(),
574
- cwd: this.executor.getCurrentWorkingDirectory(),
575
604
  });
576
605
  }
577
606
  catch (error) {
578
607
  console.error('Failed to send structured content:', error);
579
608
  }
580
609
  }
581
- /**
582
- * Send response
583
- */
584
- sendResponse(messageId, result) {
610
+ sendResponse(messageId, threadId, result) {
585
611
  try {
612
+ // Resolve CWD from thread executor if possible
613
+ let cwd;
614
+ try {
615
+ if (threadId) {
616
+ cwd = this.threadPool.getExecutor(threadId).getCurrentWorkingDirectory();
617
+ }
618
+ }
619
+ catch {
620
+ // Ignore — thread may have been deleted
621
+ }
586
622
  this.wsClient.send({
587
623
  type: 'response',
588
624
  messageId,
@@ -591,25 +627,22 @@ You can also use natural language commands to control Claude Code CLI.`,
591
627
  error: result.error,
592
628
  sessionAbbr: result.sessionAbbr,
593
629
  openId: this.currentOpenId,
630
+ threadId,
631
+ threads: result.threads,
632
+ cwd,
594
633
  timestamp: Date.now(),
595
- cwd: this.executor.getCurrentWorkingDirectory(),
596
634
  });
597
635
  }
598
636
  catch (error) {
599
637
  console.error('Failed to send response:', error);
600
638
  }
601
639
  }
602
- /**
603
- * Detect whether a command is available on PATH
604
- */
640
+ // ── Backend switching ─────────────────────────────────────────────────────
605
641
  checkCommand(cmd, args) {
606
642
  return new Promise((resolve) => {
607
643
  (0, child_process_1.execFile)(cmd, args, { timeout: 5000 }, (err) => resolve(!err));
608
644
  });
609
645
  }
610
- /**
611
- * Detect all installed AI backends
612
- */
613
646
  async detectBackends() {
614
647
  const [claudeInstalled, geminiInstalled] = await Promise.all([
615
648
  this.checkCommand('claude', ['--version']),
@@ -620,23 +653,16 @@ You can also use natural language commands to control Claude Code CLI.`,
620
653
  { id: 'gemini', label: 'Gemini CLI', installed: geminiInstalled },
621
654
  ];
622
655
  }
623
- /**
624
- * Handle /backend command
625
- * Usage:
626
- * /backend — list available backends
627
- * /backend <name|index> — switch to the specified backend
628
- */
629
- async handleBackendCommand(messageId, trimmed) {
656
+ async handleBackendCommand(messageId, threadId, trimmed) {
630
657
  const parts = trimmed.split(/\s+/);
631
658
  const arg = parts[1];
632
659
  const currentConfig = this.config.get('executor') ?? { type: 'auto' };
633
660
  const currentType = currentConfig.type;
634
661
  const backends = await this.detectBackends();
635
662
  const installed = backends.filter((b) => b.installed);
636
- // List mode
637
663
  if (!arg) {
638
664
  if (installed.length === 0) {
639
- this.sendResponse(messageId, {
665
+ this.sendResponse(messageId, threadId, {
640
666
  success: false,
641
667
  error: 'No supported AI backends found.\n\nMake sure Claude Code is installed: npm install -g @anthropic-ai/claude-code',
642
668
  });
@@ -648,13 +674,12 @@ You can also use natural language commands to control Claude Code CLI.`,
648
674
  const active = b.id === currentType || isClaudeActive ? ' ★ (active)' : '';
649
675
  return `${i + 1}. ${b.label}${active}`;
650
676
  });
651
- this.sendResponse(messageId, {
677
+ this.sendResponse(messageId, threadId, {
652
678
  success: true,
653
679
  output: `🤖 Available AI backends:\n${lines.join('\n')}\n\nSwitch with: /backend <index> or /backend <name>`,
654
680
  });
655
681
  return;
656
682
  }
657
- // Switch mode — resolve by index or name
658
683
  const index = parseInt(arg, 10);
659
684
  let target;
660
685
  if (!isNaN(index) && index >= 1 && index <= installed.length) {
@@ -664,33 +689,32 @@ You can also use natural language commands to control Claude Code CLI.`,
664
689
  target = installed.find((b) => b.id === arg || b.label.toLowerCase().includes(arg.toLowerCase()));
665
690
  }
666
691
  if (!target) {
667
- this.sendResponse(messageId, {
692
+ this.sendResponse(messageId, threadId, {
668
693
  success: false,
669
694
  error: `Backend "${arg}" not found. Use /backend to see available options.`,
670
695
  });
671
696
  return;
672
697
  }
673
- // Persist to config
674
698
  const newConfig = { ...currentConfig, type: target.id };
675
699
  await this.config.set('executor', newConfig);
676
- // Hot-swap executor without restart
677
- const cwd = this.executor.getCurrentWorkingDirectory();
678
- if (typeof this.executor.destroy === 'function') {
679
- this.executor.destroy();
680
- }
681
- this.executor = (0, executor_1.createExecutor)(this.directoryGuard, newConfig, cwd);
682
- this.sendResponse(messageId, {
700
+ await this.threadPool.switchBackend(newConfig);
701
+ this.sendResponse(messageId, threadId, {
683
702
  success: true,
684
- output: `✅ Backend switched to: ${target.label}`,
703
+ output: `✅ Backend switched to: ${target.label}\n\nAll threads will use the new backend for future commands.`,
685
704
  });
686
705
  }
687
706
  /**
688
- * Destroy handler
707
+ * Destroy handler and all executors
689
708
  */
690
- destroy() {
709
+ async destroy() {
691
710
  this.isDestroyed = true;
692
- this.isExecuting = false;
693
711
  this.notificationAdapter.unregister();
712
+ try {
713
+ await this.threadPool.destroyAll();
714
+ }
715
+ catch (err) {
716
+ console.error('Error destroying thread executors:', err);
717
+ }
694
718
  }
695
719
  }
696
720
  exports.MessageHandler = MessageHandler;