@yu_robotics/remote-cli 1.6.8 → 1.6.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.
@@ -5,7 +5,13 @@ const hooks_1 = require("../hooks");
5
5
  const FileReadDetector_1 = require("../utils/FileReadDetector");
6
6
  const MachineCommands_1 = require("../machines/MachineCommands");
7
7
  const child_process_1 = require("child_process");
8
+ const promises_1 = require("fs/promises");
9
+ const os_1 = require("os");
10
+ const path_1 = require("path");
8
11
  const config_1 = require("../types/config");
12
+ const uuid_1 = require("uuid");
13
+ const QUEUE_CONFIRMATION_TTL_MS = 5 * 60 * 1000;
14
+ const MAX_THREAD_QUEUE_LENGTH = 10;
9
15
  /**
10
16
  * Thread-aware Message Handler.
11
17
  * Each thread has its own executor process managed by ThreadExecutorPool.
@@ -22,6 +28,10 @@ class MessageHandler {
22
28
  notificationAdapter;
23
29
  machineCommands;
24
30
  pendingReplaces = new Map();
31
+ threadQueues = new Map();
32
+ pendingQueueConfirmations = new Map();
33
+ pausedQueues = new Set();
34
+ messageOpenIds = new Map();
25
35
  constructor(wsClient, threadPool, threadManager, directoryGuard, config) {
26
36
  this.wsClient = wsClient;
27
37
  this.threadPool = threadPool;
@@ -71,6 +81,7 @@ class MessageHandler {
71
81
  async handleCommandMessage(message) {
72
82
  const { messageId, content, attachments, workingDirectory, openId, isSlashCommand, threadId } = message;
73
83
  this.currentOpenId = openId;
84
+ this.messageOpenIds.set(message.messageId, openId);
74
85
  this.notificationAdapter.setCurrentOpenId(openId);
75
86
  // Resolve target thread — fall back to default if not specified
76
87
  const thread = threadId
@@ -85,6 +96,23 @@ class MessageHandler {
85
96
  }
86
97
  const resolvedThreadId = thread.id;
87
98
  const executor = this.threadPool.getExecutor(resolvedThreadId);
99
+ if (content?.trim() === '/queue' || content?.trim().startsWith('/queue ')) {
100
+ await this.handleQueueCommand(messageId, resolvedThreadId, content.trim());
101
+ return;
102
+ }
103
+ // Backend switching has its own lifecycle handling and must run before the
104
+ // normal command busy flag is acquired.
105
+ if (content?.trim() === '/backend' || content?.trim().startsWith('/backend ')) {
106
+ if (this.threadPool.isThreadBusy(resolvedThreadId)) {
107
+ this.sendResponse(messageId, resolvedThreadId, {
108
+ success: false,
109
+ error: `Thread "${thread.name}" is busy. Send /abort to cancel the running task before switching backend.`,
110
+ });
111
+ return;
112
+ }
113
+ await this.handleBackendCommand(messageId, resolvedThreadId, content.trim());
114
+ return;
115
+ }
88
116
  // Handle /abort for this specific thread (bypasses busy check)
89
117
  if (content?.trim() === '/abort') {
90
118
  await this.handleAbortCommand(messageId, resolvedThreadId, executor);
@@ -120,9 +148,35 @@ class MessageHandler {
120
148
  await this.executePendingReplace(messageId, resolvedThreadId, pendingKey, content);
121
149
  return;
122
150
  }
151
+ if (isSlashCommand || content?.trim().startsWith('/')) {
152
+ this.sendResponse(messageId, resolvedThreadId, {
153
+ success: false,
154
+ error: `Thread "${thread.name}" is busy. Send /abort to cancel the running task, or use /queue to inspect queued messages.`,
155
+ });
156
+ }
157
+ else {
158
+ if ((this.threadQueues.get(resolvedThreadId)?.length ?? 0) >= MAX_THREAD_QUEUE_LENGTH) {
159
+ this.sendResponse(messageId, resolvedThreadId, {
160
+ success: false,
161
+ error: `Thread queue is full (${MAX_THREAD_QUEUE_LENGTH} messages). Use /abort to clear it.`,
162
+ });
163
+ return;
164
+ }
165
+ const queueConfirmation = this.createQueueConfirmation(resolvedThreadId, thread.name, content, attachments, openId, messageId, executor);
166
+ this.sendResponse(messageId, resolvedThreadId, {
167
+ success: false,
168
+ output: `⏳ Thread "${thread.name}" is busy. Confirm whether to add this message to its queue.`,
169
+ queueConfirmation,
170
+ });
171
+ }
172
+ return;
173
+ }
174
+ const queueSensitiveCommand = /^\/(?:clear|compact|cd|model|effort)(?:\s|$)/.test(content?.trim() ?? '')
175
+ || /^\/thread\s+delete(?:\s|$)/.test(content?.trim() ?? '');
176
+ if (queueSensitiveCommand && this.hasThreadQueueState(resolvedThreadId)) {
123
177
  this.sendResponse(messageId, resolvedThreadId, {
124
178
  success: false,
125
- error: `Thread "${thread.name}" is busy. Send /abort to cancel the running task, or use another thread.`,
179
+ error: 'This thread has queued messages. Send /abort to clear the current task and queue before changing its execution context.',
126
180
  });
127
181
  return;
128
182
  }
@@ -160,7 +214,10 @@ class MessageHandler {
160
214
  }
161
215
  const expandedContent = this.expandCommandShortcuts(content);
162
216
  const processedContent = (0, FileReadDetector_1.processFileReadContent)(expandedContent);
163
- await this.executeCommand(messageId, resolvedThreadId, processedContent, executor, attachments);
217
+ const success = await this.executeCommand(messageId, resolvedThreadId, processedContent, executor, attachments);
218
+ if (!success && this.threadQueues.has(resolvedThreadId)) {
219
+ this.pausedQueues.add(resolvedThreadId);
220
+ }
164
221
  }
165
222
  catch (error) {
166
223
  this.threadPool.setThreadError(resolvedThreadId, true);
@@ -171,6 +228,8 @@ class MessageHandler {
171
228
  }
172
229
  finally {
173
230
  this.threadPool.setThreadBusy(resolvedThreadId, false);
231
+ if (!this.pausedQueues.has(resolvedThreadId))
232
+ void this.startNextQueuedCommand(resolvedThreadId);
174
233
  }
175
234
  }
176
235
  /**
@@ -178,20 +237,21 @@ class MessageHandler {
178
237
  */
179
238
  async handleAbortCommand(messageId, threadId, executor) {
180
239
  const wasExecuting = this.threadPool.isThreadBusy(threadId);
240
+ const clearedCount = this.clearThreadQueue(threadId);
181
241
  const aborted = await executor.abort();
182
242
  if (aborted) {
183
243
  this.threadPool.setThreadBusy(threadId, false);
184
244
  this.sendResponse(messageId, threadId, {
185
245
  success: true,
186
- output: wasExecuting
187
- ? '✅ Current command has been aborted'
188
- : '⚠️ No command was executing, but executor has been reset',
246
+ output: `${wasExecuting ? '✅ Current command has been aborted' : '⚠️ No command was executing, but executor has been reset'}${clearedCount ? `\n🗑️ Cleared ${clearedCount} queued message${clearedCount === 1 ? '' : 's'}.` : ''}`,
189
247
  });
190
248
  }
191
249
  else {
192
250
  this.sendResponse(messageId, threadId, {
193
251
  success: true,
194
- output: 'ℹ️ No command is currently executing',
252
+ output: clearedCount
253
+ ? `ℹ️ No command is currently executing\n🗑️ Cleared ${clearedCount} queued message${clearedCount === 1 ? '' : 's'}.`
254
+ : 'ℹ️ No command is currently executing',
195
255
  });
196
256
  }
197
257
  }
@@ -231,12 +291,24 @@ class MessageHandler {
231
291
  const cwd = executor.getCurrentWorkingDirectory();
232
292
  const allowedDirs = this.directoryGuard.getAllowedDirectories();
233
293
  const threads = this.threadPool.getSummaries();
294
+ const thread = this.threadManager.getThread(threadId);
295
+ const backend = this.threadPool.getBackendKey(threadId);
296
+ const model = thread?.models?.[backend] ?? (backend === 'claude' ? thread?.model : undefined);
297
+ const effort = thread?.efforts?.[backend] ?? 'auto';
234
298
  const threadList = threads
235
- .map(t => ` • ${t.name}${t.status === 'running' ? ' 🔄' : t.status === 'error' ? ' ❌' : ' ✅'} (${t.status})`)
299
+ .map(t => {
300
+ const queued = this.threadQueues.get(t.id)?.length ?? 0;
301
+ const queueSuffix = queued > 0 ? `, queue: ${queued}` : '';
302
+ return ` • ${t.name}${t.status === 'running' ? ' 🔄' : t.status === 'error' ? ' ❌' : ' ✅'} (${t.status}, backend: ${t.backend ?? 'claude'}${queueSuffix})`;
303
+ })
236
304
  .join('\n');
237
305
  this.sendResponse(messageId, threadId, {
238
306
  success: true,
239
307
  output: `📊 Status:
308
+ - Thread: ${thread?.name ?? threadId}
309
+ - Backend: ${backend}
310
+ - Model: ${model ?? 'backend default'}
311
+ - Reasoning effort: ${effort}
240
312
  - Working Directory: ${cwd}
241
313
  - Allowed Directories: ${allowedDirs.join(', ')}
242
314
  - Connection: Active
@@ -244,19 +316,66 @@ class MessageHandler {
244
316
  });
245
317
  return true;
246
318
  }
319
+ if (trimmed === '/context') {
320
+ const thread = this.threadManager.getThread(threadId);
321
+ const backend = this.threadPool.getBackendKey(threadId);
322
+ const model = thread?.models?.[backend] ?? (backend === 'claude' ? thread?.model : undefined);
323
+ const effort = thread?.efforts?.[backend] ?? 'auto';
324
+ const sessionId = typeof executor.getSessionId === 'function' ? executor.getSessionId() : null;
325
+ const queued = this.threadQueues.get(threadId)?.length ?? 0;
326
+ const pending = Array.from(this.pendingQueueConfirmations.values())
327
+ .filter((confirmation) => confirmation.info.threadId === threadId).length;
328
+ this.sendResponse(messageId, threadId, {
329
+ success: true,
330
+ output: `🧠 Context:
331
+ - Thread: ${thread?.name ?? threadId}
332
+ - Backend: ${backend}
333
+ - Model: ${model ?? 'backend default'}
334
+ - Reasoning effort: ${effort}
335
+ - Working Directory: ${executor.getCurrentWorkingDirectory()}
336
+ - Session: ${sessionId ?? 'not started'}
337
+ - Queue: ${queued} confirmed, ${pending} awaiting confirmation
338
+ - Token usage: exact usage is not exposed by the active remote transport
339
+
340
+ Use /compact to reduce conversation context or /clear to start a fresh context.`,
341
+ });
342
+ return true;
343
+ }
344
+ if (trimmed === '/skills') {
345
+ const backend = this.threadPool.getBackendKey(threadId);
346
+ if (backend === 'claude' || backend === 'agy') {
347
+ await this.executeSlashCommand(messageId, threadId, trimmed, executor);
348
+ }
349
+ else {
350
+ const skills = await this.listLocalSkills(executor.getCurrentWorkingDirectory(), backend);
351
+ this.sendResponse(messageId, threadId, {
352
+ success: true,
353
+ output: skills.length > 0
354
+ ? `🧩 Available skills (local discovery):\n${skills.map((skill) => `- ${skill}`).join('\n')}`
355
+ : '🧩 No local skills were discovered for the Codex backend.\n\nExpected locations include .agents/skills and ~/.codex/skills.',
356
+ });
357
+ }
358
+ return true;
359
+ }
247
360
  if (trimmed === '/help') {
248
361
  this.sendResponse(messageId, threadId, {
249
362
  success: true,
250
363
  output: `📖 Available commands:
251
364
  - /help - Show this help message
252
- - /status - Show current status and threads
365
+ - /status - Show backend, model, effort, queue, and thread status
366
+ - /context - Show current session context and queue diagnostics
367
+ - /skills - List available skills for the active backend
253
368
  - /abort - Abort the currently executing command in this thread
369
+ - /queue [clear|continue|confirm <id>|cancel <id>] - Inspect or manage this thread's command queue
254
370
  - /clear - Clear conversation context for this thread
255
371
  - /compact - Compress conversation history to reduce context size
256
372
  - /cd <directory> - Change working directory for this thread
257
- - /model [name] - Show available models, or switch the AI model for this thread (persists across sessions)
258
- - /effort [auto|level] - Show or set reasoning effort for this thread
259
- - /backend - List available AI backends and switch between them
373
+ - /model [name] - Show models for the active backend, or set this thread's model
374
+ - /effort [auto|low|medium|high] - Show or set effort for Codex/AGY (Claude Code is unsupported)
375
+ - /backend - List backends and show the current thread's effective backend
376
+ - /backend <index> - Switch all threads and clear per-thread backend overrides
377
+ - /backend <index> @ - Switch only the current thread
378
+ - /backend default @ - Clear the current thread override and follow the global backend
260
379
  - /thread list - List all threads with their status
261
380
  - /thread new [name] - Create a new thread
262
381
  - /thread delete <name> - Delete a thread (only when idle)
@@ -340,7 +459,7 @@ You can also use natural language commands to control Claude Code CLI.`,
340
459
  }
341
460
  if (trimmed === '/effort' || trimmed.startsWith('/effort ')) {
342
461
  const executorConfig = this.config.get('executor') ?? { type: 'auto' };
343
- const key = (0, config_1.backendKeyOf)(executorConfig.type);
462
+ const key = this.threadPool.getBackendKey(threadId);
344
463
  if (key !== 'codex' && key !== 'agy') {
345
464
  this.sendResponse(messageId, threadId, {
346
465
  success: false,
@@ -432,7 +551,7 @@ You can also use natural language commands to control Claude Code CLI.`,
432
551
  // thread.models[backendKey]. The legacy `model` field is kept in
433
552
  // sync for the Claude backend only.
434
553
  const executorConfig = this.config.get('executor') ?? { type: 'auto' };
435
- const key = (0, config_1.backendKeyOf)(executorConfig.type);
554
+ const key = this.threadPool.getBackendKey(threadId);
436
555
  const current = this.threadManager.getThread(threadId);
437
556
  const models = { ...current?.models, [key]: modelArg };
438
557
  await this.threadManager.updateThread(threadId, key === 'claude' ? { models, model: modelArg } : { models });
@@ -449,6 +568,170 @@ You can also use natural language commands to control Claude Code CLI.`,
449
568
  }
450
569
  return false;
451
570
  }
571
+ createQueueConfirmation(threadId, threadName, content, attachments, openId, messageId, executor) {
572
+ this.removeExpiredQueueConfirmations();
573
+ const id = (0, uuid_1.v4)();
574
+ const info = {
575
+ id,
576
+ threadId,
577
+ threadName,
578
+ backend: this.threadPool.getBackendKey(threadId),
579
+ cwd: executor.getCurrentWorkingDirectory(),
580
+ preview: content.replace(/\s+/g, ' ').trim().slice(0, 240),
581
+ pendingCount: this.threadQueues.get(threadId)?.length ?? 0,
582
+ expiresAt: Date.now() + QUEUE_CONFIRMATION_TTL_MS,
583
+ };
584
+ this.pendingQueueConfirmations.set(id, {
585
+ info,
586
+ command: { messageId, threadId, content, attachments, openId, enqueuedAt: Date.now() },
587
+ });
588
+ setTimeout(() => {
589
+ const pending = this.pendingQueueConfirmations.get(id);
590
+ if (pending && pending.info.expiresAt <= Date.now())
591
+ this.pendingQueueConfirmations.delete(id);
592
+ }, QUEUE_CONFIRMATION_TTL_MS).unref?.();
593
+ return info;
594
+ }
595
+ removeExpiredQueueConfirmations() {
596
+ const now = Date.now();
597
+ for (const [id, pending] of this.pendingQueueConfirmations) {
598
+ if (pending.info.expiresAt <= now)
599
+ this.pendingQueueConfirmations.delete(id);
600
+ }
601
+ }
602
+ clearThreadQueue(threadId) {
603
+ let cleared = this.threadQueues.get(threadId)?.length ?? 0;
604
+ this.threadQueues.delete(threadId);
605
+ for (const [id, pending] of this.pendingQueueConfirmations) {
606
+ if (pending.info.threadId === threadId) {
607
+ this.pendingQueueConfirmations.delete(id);
608
+ cleared += 1;
609
+ }
610
+ }
611
+ this.pausedQueues.delete(threadId);
612
+ return cleared;
613
+ }
614
+ clearAllQueues() {
615
+ const threadIds = new Set([
616
+ ...this.threadQueues.keys(),
617
+ ...this.pausedQueues,
618
+ ...Array.from(this.pendingQueueConfirmations.values()).map((pending) => pending.info.threadId),
619
+ ]);
620
+ return Array.from(threadIds).reduce((total, threadId) => total + this.clearThreadQueue(threadId), 0);
621
+ }
622
+ async handleQueueCommand(messageId, threadId, trimmed) {
623
+ this.removeExpiredQueueConfirmations();
624
+ const parts = trimmed.split(/\s+/);
625
+ const action = parts[1]?.toLowerCase();
626
+ const id = parts[2];
627
+ if (action === 'confirm' && id) {
628
+ const pending = this.pendingQueueConfirmations.get(id);
629
+ const currentCwd = this.threadPool.getExecutor(threadId).getCurrentWorkingDirectory();
630
+ if (!pending || pending.info.threadId !== threadId || pending.info.expiresAt <= Date.now()
631
+ || pending.info.backend !== this.threadPool.getBackendKey(threadId) || pending.info.cwd !== currentCwd
632
+ || (pending.command.openId && pending.command.openId !== this.getMessageOpenId(messageId))) {
633
+ this.pendingQueueConfirmations.delete(id);
634
+ this.sendResponse(messageId, threadId, { success: false, error: 'Queue confirmation is no longer valid. The message was not queued.' });
635
+ return;
636
+ }
637
+ this.pendingQueueConfirmations.delete(id);
638
+ const queue = this.threadQueues.get(threadId) ?? [];
639
+ if (queue.length >= MAX_THREAD_QUEUE_LENGTH) {
640
+ this.sendResponse(messageId, threadId, { success: false, error: `This thread queue is full (${MAX_THREAD_QUEUE_LENGTH} messages).` });
641
+ return;
642
+ }
643
+ queue.push({ ...pending.command, messageId: parts[3] || pending.command.messageId });
644
+ this.threadQueues.set(threadId, queue);
645
+ this.sendResponse(messageId, threadId, {
646
+ success: true,
647
+ output: `✅ Added to queue for ${pending.info.threadName}.\nQueue position: ${queue.length}\nPending messages for this thread: ${queue.length}`,
648
+ });
649
+ void this.startNextQueuedCommand(threadId);
650
+ return;
651
+ }
652
+ if (action === 'cancel' && id) {
653
+ const pending = this.pendingQueueConfirmations.get(id);
654
+ if (pending?.info.threadId === threadId) {
655
+ this.pendingQueueConfirmations.delete(id);
656
+ this.sendResponse(messageId, threadId, { success: true, output: '❌ Message was not queued.' });
657
+ }
658
+ else {
659
+ this.sendResponse(messageId, threadId, { success: false, error: 'Queue confirmation is no longer valid.' });
660
+ }
661
+ return;
662
+ }
663
+ if (action === 'clear') {
664
+ const cleared = this.clearThreadQueue(threadId);
665
+ this.sendResponse(messageId, threadId, {
666
+ success: true,
667
+ output: cleared ? `🗑️ Cleared ${cleared} queued message${cleared === 1 ? '' : 's'}.` : 'ℹ️ This thread queue is empty.',
668
+ });
669
+ return;
670
+ }
671
+ if (action === 'continue') {
672
+ this.pausedQueues.delete(threadId);
673
+ this.sendResponse(messageId, threadId, { success: true, output: '▶️ Queue resumed.' });
674
+ void this.startNextQueuedCommand(threadId);
675
+ return;
676
+ }
677
+ const lines = ['📋 Thread queues:'];
678
+ for (const summary of this.threadPool.getSummaries()) {
679
+ const queued = this.threadQueues.get(summary.id) ?? [];
680
+ const pending = Array.from(this.pendingQueueConfirmations.values()).filter((item) => item.info.threadId === summary.id);
681
+ if (summary.status !== 'running' && queued.length === 0 && pending.length === 0 && !this.pausedQueues.has(summary.id))
682
+ continue;
683
+ lines.push(`\n${summary.name} (${summary.backend ?? 'global'}) — ${summary.status}`);
684
+ lines.push(` Confirmed: ${queued.length}; awaiting confirmation: ${pending.length}`);
685
+ queued.forEach((command, index) => lines.push(` ${index + 1}. ${command.content.replace(/\s+/g, ' ').slice(0, 160)}`));
686
+ if (this.pausedQueues.has(summary.id))
687
+ lines.push(' ⏸️ Paused after a failed task. Use /queue continue or /abort.');
688
+ }
689
+ if (lines.length === 1)
690
+ lines.push('No active or pending thread queues.');
691
+ this.sendResponse(messageId, threadId, { success: true, output: lines.join('\n') });
692
+ }
693
+ async startNextQueuedCommand(threadId) {
694
+ if (this.threadPool.isThreadBusy(threadId) || this.pausedQueues.has(threadId))
695
+ return;
696
+ const queue = this.threadQueues.get(threadId);
697
+ const command = queue?.shift();
698
+ if (!command) {
699
+ this.threadQueues.delete(threadId);
700
+ return;
701
+ }
702
+ if (queue?.length === 0)
703
+ this.threadQueues.delete(threadId);
704
+ const thread = this.threadManager.getThread(threadId);
705
+ if (!thread)
706
+ return;
707
+ this.currentOpenId = command.openId;
708
+ this.messageOpenIds.set(command.messageId, command.openId);
709
+ this.notificationAdapter.setCurrentOpenId(command.openId);
710
+ this.threadPool.setThreadBusy(threadId, true);
711
+ try {
712
+ const executor = this.threadPool.getExecutor(threadId);
713
+ await this.threadManager.updateThread(threadId, { lastActiveAt: Date.now() });
714
+ const processedContent = (0, FileReadDetector_1.processFileReadContent)(this.expandCommandShortcuts(command.content));
715
+ const success = await this.executeCommand(command.messageId, threadId, processedContent, executor, command.attachments);
716
+ if (!success) {
717
+ this.pausedQueues.add(threadId);
718
+ }
719
+ }
720
+ catch (error) {
721
+ this.pausedQueues.add(threadId);
722
+ this.sendResponse(command.messageId, threadId, { success: false, error: error instanceof Error ? error.message : 'Queued command failed' });
723
+ }
724
+ finally {
725
+ this.threadPool.setThreadBusy(threadId, false);
726
+ if (!this.pausedQueues.has(threadId))
727
+ void this.startNextQueuedCommand(threadId);
728
+ }
729
+ }
730
+ hasThreadQueueState(threadId) {
731
+ if ((this.threadQueues.get(threadId)?.length ?? 0) > 0)
732
+ return true;
733
+ return Array.from(this.pendingQueueConfirmations.values()).some((pending) => pending.info.threadId === threadId);
734
+ }
452
735
  /**
453
736
  * Bare /model: show the thread's current model on the active backend plus
454
737
  * the available models. Listing source per backend (all verified live):
@@ -459,7 +742,7 @@ You can also use natural language commands to control Claude Code CLI.`,
459
742
  */
460
743
  async handleModelList(messageId, threadId) {
461
744
  const executorConfig = this.config.get('executor') ?? { type: 'auto' };
462
- const key = (0, config_1.backendKeyOf)(executorConfig.type);
745
+ const key = this.threadPool.getBackendKey(threadId);
463
746
  const thread = this.threadManager.getThread(threadId);
464
747
  const current = thread?.models?.[key] ?? (key === 'claude' ? thread?.model : undefined);
465
748
  const backendLabel = key === 'claude' ? 'Claude Code' : key === 'agy' ? 'AGY CLI' : 'Codex CLI';
@@ -553,7 +836,8 @@ You can also use natural language commands to control Claude Code CLI.`,
553
836
  const lines = summaries.map(t => {
554
837
  const icon = t.status === 'running' ? '🔄' : t.status === 'error' ? '❌' : '✅';
555
838
  const current = t.id === callerThreadId ? ' ← (this thread)' : '';
556
- return `${icon} ${t.name}${current}`;
839
+ const backend = t.backend ? ` [${t.backend}]` : '';
840
+ return `${icon} ${t.name}${backend}${current}`;
557
841
  });
558
842
  this.sendResponse(messageId, callerThreadId, {
559
843
  success: true,
@@ -567,7 +851,7 @@ You can also use natural language commands to control Claude Code CLI.`,
567
851
  const callerExecutor = this.threadPool.getExecutor(callerThread.id);
568
852
  const cwd = callerExecutor.getCurrentWorkingDirectory();
569
853
  try {
570
- const newThread = await this.threadManager.createThread(name || this.generateThreadName(), cwd);
854
+ const newThread = await this.threadManager.createThread(name || this.generateThreadName(), cwd, this.threadPool.getBackendKey(callerThread.id));
571
855
  // Use newThread.id so the router maps this card to the new thread,
572
856
  // enabling the user to reply to this card to target the new thread.
573
857
  this.sendResponse(messageId, newThread.id, {
@@ -876,18 +1160,43 @@ You can also use natural language commands to control Claude Code CLI.`,
876
1160
  '/help', '/model', '/skills', '/usage', '/config', '/changelog',
877
1161
  '/agents', '/permissions', '/hooks', '/credits',
878
1162
  ]);
1163
+ async listLocalSkills(cwd, backend) {
1164
+ const roots = backend === 'codex'
1165
+ ? [(0, path_1.join)(cwd, '.agents', 'skills'), (0, path_1.join)((0, os_1.homedir)(), '.codex', 'skills'), (0, path_1.join)((0, os_1.homedir)(), '.codex', 'skills', '.system')]
1166
+ : [(0, path_1.join)(cwd, '.claude', 'skills'), (0, path_1.join)((0, os_1.homedir)(), '.claude', 'skills')];
1167
+ const skills = [];
1168
+ const seen = new Set();
1169
+ for (const root of roots) {
1170
+ let entries;
1171
+ try {
1172
+ entries = await (0, promises_1.readdir)(root, { withFileTypes: true });
1173
+ }
1174
+ catch {
1175
+ continue;
1176
+ }
1177
+ for (const entry of entries) {
1178
+ if (!entry.isDirectory() || seen.has(entry.name))
1179
+ continue;
1180
+ const skillPath = (0, path_1.join)(root, entry.name, 'SKILL.md');
1181
+ try {
1182
+ const content = await (0, promises_1.readFile)(skillPath, 'utf8');
1183
+ const description = content.match(/^description:\s*(.+)$/mi)?.[1]?.trim();
1184
+ skills.push(description ? `${entry.name} — ${description}` : entry.name);
1185
+ seen.add(entry.name);
1186
+ }
1187
+ catch {
1188
+ continue;
1189
+ }
1190
+ }
1191
+ }
1192
+ return skills.sort((left, right) => left.localeCompare(right));
1193
+ }
879
1194
  /**
880
- * Which backend slash commands should be forwarded to, based on config.
1195
+ * Which backend slash commands should be forwarded to for a thread.
881
1196
  * 'auto'/claude-* always resolve to Claude; legacy 'gemini' means AGY.
882
1197
  */
883
- resolveSlashBackend() {
884
- const cfg = this.config.get('executor') ?? { type: 'auto' };
885
- const t = cfg.type;
886
- if (t === 'agy' || t === 'gemini')
887
- return 'agy';
888
- if (t === 'codex')
889
- return 'codex';
890
- return 'claude';
1198
+ resolveSlashBackend(threadId) {
1199
+ return this.threadPool.getBackendKey(threadId);
891
1200
  }
892
1201
  /**
893
1202
  * Execute passthrough slash command on the active backend's CLI.
@@ -901,7 +1210,7 @@ You can also use natural language commands to control Claude Code CLI.`,
901
1210
  * implemented through app-server methods instead.
902
1211
  */
903
1212
  async executeSlashCommand(messageId, threadId, command, executor) {
904
- const backend = this.resolveSlashBackend();
1213
+ const backend = this.resolveSlashBackend(threadId);
905
1214
  if (backend === 'codex') {
906
1215
  this.sendResponse(messageId, threadId, {
907
1216
  success: false,
@@ -1009,7 +1318,7 @@ You can also use natural language commands to control Claude Code CLI.`,
1009
1318
  success: false,
1010
1319
  error: `❌ Auto-compact failed: ${compactResult.error}\n\nUse /compact to try again, or /clear to start fresh.`,
1011
1320
  });
1012
- return;
1321
+ return false;
1013
1322
  }
1014
1323
  this.sendStreamChunk(messageId, threadId, '✅ Compaction done. Retrying your request...\n');
1015
1324
  const retryResult = await executor.execute(content, {
@@ -1021,28 +1330,30 @@ You can also use natural language commands to control Claude Code CLI.`,
1021
1330
  attachments,
1022
1331
  });
1023
1332
  this.sendResponse(messageId, threadId, { success: retryResult.success, error: retryResult.error, threads: this.threadPool.getSummaries() });
1024
- return;
1333
+ return retryResult.success;
1025
1334
  }
1026
1335
  this.sendResponse(messageId, threadId, {
1027
1336
  success: false,
1028
1337
  error: '❌ Conversation history too long.\n\nUse /compact to compress it, or /clear to start fresh.',
1029
1338
  });
1030
- return;
1339
+ return false;
1031
1340
  }
1032
1341
  this.sendResponse(messageId, threadId, { success: result.success, error: result.error, threads: this.threadPool.getSummaries() });
1342
+ return result.success;
1033
1343
  }
1034
1344
  catch (error) {
1035
1345
  this.sendResponse(messageId, threadId, {
1036
1346
  success: false,
1037
1347
  error: error instanceof Error ? error.message : 'Execution error',
1038
1348
  });
1349
+ return false;
1039
1350
  }
1040
1351
  }
1041
1352
  async clearInvalidCodexModel(threadId, executor, error) {
1042
1353
  if (!error || !this.isUnavailableCodexModelError(error))
1043
1354
  return false;
1044
1355
  const executorConfig = this.config.get('executor') ?? { type: 'auto' };
1045
- if ((0, config_1.backendKeyOf)(executorConfig.type) !== 'codex')
1356
+ if (this.threadPool.getBackendKey(threadId) !== 'codex')
1046
1357
  return false;
1047
1358
  const thread = this.threadManager.getThread(threadId);
1048
1359
  if (!thread?.models?.codex)
@@ -1058,6 +1369,9 @@ You can also use natural language commands to control Claude Code CLI.`,
1058
1369
  || /unknown Codex model/i.test(error);
1059
1370
  }
1060
1371
  // ── Outgoing message helpers ──────────────────────────────────────────────
1372
+ getMessageOpenId(messageId) {
1373
+ return this.messageOpenIds.get(messageId) ?? this.currentOpenId;
1374
+ }
1061
1375
  sendStreamChunk(messageId, threadId, chunk) {
1062
1376
  try {
1063
1377
  this.wsClient.send({
@@ -1065,7 +1379,7 @@ You can also use natural language commands to control Claude Code CLI.`,
1065
1379
  messageId,
1066
1380
  chunk,
1067
1381
  streamType: 'text',
1068
- openId: this.currentOpenId,
1382
+ openId: this.getMessageOpenId(messageId),
1069
1383
  threadId,
1070
1384
  timestamp: Date.now(),
1071
1385
  });
@@ -1081,7 +1395,7 @@ You can also use natural language commands to control Claude Code CLI.`,
1081
1395
  messageId,
1082
1396
  streamType: 'tool_use',
1083
1397
  toolUse,
1084
- openId: this.currentOpenId,
1398
+ openId: this.getMessageOpenId(messageId),
1085
1399
  threadId,
1086
1400
  timestamp: Date.now(),
1087
1401
  });
@@ -1097,7 +1411,7 @@ You can also use natural language commands to control Claude Code CLI.`,
1097
1411
  messageId,
1098
1412
  streamType: 'tool_result',
1099
1413
  toolResult,
1100
- openId: this.currentOpenId,
1414
+ openId: this.getMessageOpenId(messageId),
1101
1415
  threadId,
1102
1416
  timestamp: Date.now(),
1103
1417
  });
@@ -1112,7 +1426,7 @@ You can also use natural language commands to control Claude Code CLI.`,
1112
1426
  type: 'stream',
1113
1427
  messageId,
1114
1428
  streamType: 'redacted_thinking',
1115
- openId: this.currentOpenId,
1429
+ openId: this.getMessageOpenId(messageId),
1116
1430
  threadId,
1117
1431
  timestamp: Date.now(),
1118
1432
  });
@@ -1128,7 +1442,7 @@ You can also use natural language commands to control Claude Code CLI.`,
1128
1442
  messageId,
1129
1443
  streamType: 'plan_mode',
1130
1444
  planContent,
1131
- openId: this.currentOpenId,
1445
+ openId: this.getMessageOpenId(messageId),
1132
1446
  threadId,
1133
1447
  timestamp: Date.now(),
1134
1448
  });
@@ -1143,7 +1457,7 @@ You can also use natural language commands to control Claude Code CLI.`,
1143
1457
  type: 'structured',
1144
1458
  messageId,
1145
1459
  structuredContent,
1146
- openId: this.currentOpenId,
1460
+ openId: this.getMessageOpenId(messageId),
1147
1461
  threadId,
1148
1462
  timestamp: Date.now(),
1149
1463
  });
@@ -1171,12 +1485,14 @@ You can also use natural language commands to control Claude Code CLI.`,
1171
1485
  output: result.output,
1172
1486
  error: result.error,
1173
1487
  sessionAbbr: result.sessionAbbr,
1174
- openId: this.currentOpenId,
1488
+ openId: this.getMessageOpenId(messageId),
1175
1489
  threadId,
1176
1490
  threads: result.threads,
1491
+ queueConfirmation: result.queueConfirmation,
1177
1492
  cwd,
1178
1493
  timestamp: Date.now(),
1179
1494
  });
1495
+ this.messageOpenIds.delete(messageId);
1180
1496
  }
1181
1497
  catch (error) {
1182
1498
  console.error('Failed to send response:', error);
@@ -1203,6 +1519,7 @@ You can also use natural language commands to control Claude Code CLI.`,
1203
1519
  async handleBackendCommand(messageId, threadId, trimmed) {
1204
1520
  const parts = trimmed.split(/\s+/);
1205
1521
  const arg = parts[1];
1522
+ const isThreadOverride = parts[2] === '@';
1206
1523
  const currentConfig = this.config.get('executor') ?? { type: 'auto' };
1207
1524
  const currentType = currentConfig.type;
1208
1525
  const backends = await this.detectBackends();
@@ -1224,9 +1541,30 @@ You can also use natural language commands to control Claude Code CLI.`,
1224
1541
  const active = b.id === currentType || isClaudeActive || isAgyActive ? ' ★ (active)' : '';
1225
1542
  return `${i + 1}. ${b.label}${active}`;
1226
1543
  });
1544
+ const currentBackend = this.threadPool.getBackendKey(threadId);
1545
+ lines.push(`\nCurrent thread backend: ${currentBackend}${this.threadManager.getThread(threadId)?.backend ? ' (override)' : ' (global)'}`);
1227
1546
  this.sendResponse(messageId, threadId, {
1228
1547
  success: true,
1229
- output: `🤖 Available AI backends:\n${lines.join('\n')}\n\nSwitch with: /backend <index> or /backend <name>`,
1548
+ output: `🤖 Available AI backends:\n${lines.join('\n')}\n\nSwitch all threads with: /backend <index>\nSwitch this thread with: /backend <index> @\nReset this thread: /backend default @`,
1549
+ threads: this.threadPool.getSummaries(),
1550
+ });
1551
+ return;
1552
+ }
1553
+ if (arg === 'default' && isThreadOverride) {
1554
+ if (this.threadPool.isThreadBusy(threadId)) {
1555
+ this.sendResponse(messageId, threadId, {
1556
+ success: false,
1557
+ error: 'Cannot reset backend while this thread is running. Send /abort first.',
1558
+ });
1559
+ return;
1560
+ }
1561
+ await this.threadPool.destroyThread(threadId, { deleteData: false });
1562
+ await this.threadManager.updateThread(threadId, { backend: undefined });
1563
+ const clearedCount = this.clearThreadQueue(threadId);
1564
+ this.sendResponse(messageId, threadId, {
1565
+ success: true,
1566
+ output: `✅ Thread backend reset to the global backend (${(0, config_1.backendKeyOf)(currentType)}).${clearedCount ? `\n🗑️ Cleared ${clearedCount} queued message${clearedCount === 1 ? '' : 's'}.` : ''}`,
1567
+ threads: this.threadPool.getSummaries(),
1230
1568
  });
1231
1569
  return;
1232
1570
  }
@@ -1246,11 +1584,39 @@ You can also use natural language commands to control Claude Code CLI.`,
1246
1584
  return;
1247
1585
  }
1248
1586
  const newConfig = { ...currentConfig, type: target.id };
1587
+ if (isThreadOverride) {
1588
+ try {
1589
+ await this.threadPool.switchThreadBackend(threadId, (0, config_1.backendKeyOf)(target.id));
1590
+ const clearedCount = this.clearThreadQueue(threadId);
1591
+ this.sendResponse(messageId, threadId, {
1592
+ success: true,
1593
+ output: `✅ This thread switched to: ${target.label}${clearedCount ? `\n🗑️ Cleared ${clearedCount} queued message${clearedCount === 1 ? '' : 's'}.` : ''}\n\nUse /backend ${installed.indexOf(target) + 1} to switch all threads, or /backend default @ to follow the global backend again.`,
1594
+ threads: this.threadPool.getSummaries(),
1595
+ });
1596
+ }
1597
+ catch (error) {
1598
+ this.sendResponse(messageId, threadId, {
1599
+ success: false,
1600
+ error: error instanceof Error ? error.message : 'Failed to switch thread backend',
1601
+ });
1602
+ }
1603
+ return;
1604
+ }
1605
+ if (this.threadPool.getSummaries().some((thread) => thread.status === 'running')) {
1606
+ this.sendResponse(messageId, threadId, {
1607
+ success: false,
1608
+ error: 'Cannot switch all backends while a thread is running. Send /abort first.',
1609
+ });
1610
+ return;
1611
+ }
1249
1612
  await this.config.set('executor', newConfig);
1250
1613
  await this.threadPool.switchBackend(newConfig);
1614
+ await this.threadManager.clearBackendOverrides();
1615
+ const clearedCount = this.clearAllQueues();
1251
1616
  this.sendResponse(messageId, threadId, {
1252
1617
  success: true,
1253
- output: `✅ Backend switched to: ${target.label}\n\nAll threads will use the new backend for future commands. Conversations on the previous backend are preserved — switch back to resume them.`,
1618
+ output: `✅ Backend switched to: ${target.label}${clearedCount ? `\n🗑️ Cleared ${clearedCount} queued message${clearedCount === 1 ? '' : 's'}.` : ''}\n\nAll threads will use the new backend for future commands. Conversations on the previous backend are preserved — switch back to resume them.`,
1619
+ threads: this.threadPool.getSummaries(),
1254
1620
  });
1255
1621
  }
1256
1622
  /**