centaurus-cli 2.9.8 → 3.0.0

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 (88) hide show
  1. package/dist/cli-adapter.d.ts +7 -0
  2. package/dist/cli-adapter.d.ts.map +1 -1
  3. package/dist/cli-adapter.js +249 -155
  4. package/dist/cli-adapter.js.map +1 -1
  5. package/dist/context/handlers/docker-handler.d.ts.map +1 -1
  6. package/dist/context/handlers/docker-handler.js +2 -2
  7. package/dist/context/handlers/docker-handler.js.map +1 -1
  8. package/dist/context/handlers/ssh-handler.d.ts.map +1 -1
  9. package/dist/context/handlers/ssh-handler.js +3 -0
  10. package/dist/context/handlers/ssh-handler.js.map +1 -1
  11. package/dist/context/handlers/wsl-handler.d.ts.map +1 -1
  12. package/dist/context/handlers/wsl-handler.js +9 -3
  13. package/dist/context/handlers/wsl-handler.js.map +1 -1
  14. package/dist/index.js +49 -7
  15. package/dist/index.js.map +1 -1
  16. package/dist/mcp/mcp-server-manager.d.ts.map +1 -1
  17. package/dist/mcp/mcp-server-manager.js +17 -3
  18. package/dist/mcp/mcp-server-manager.js.map +1 -1
  19. package/dist/services/api-client.d.ts +4 -0
  20. package/dist/services/api-client.d.ts.map +1 -1
  21. package/dist/services/api-client.js +27 -18
  22. package/dist/services/api-client.js.map +1 -1
  23. package/dist/services/clipboard-service.d.ts +9 -14
  24. package/dist/services/clipboard-service.d.ts.map +1 -1
  25. package/dist/services/clipboard-service.js +83 -83
  26. package/dist/services/clipboard-service.js.map +1 -1
  27. package/dist/services/conversation-manager.d.ts.map +1 -1
  28. package/dist/services/conversation-manager.js +3 -2
  29. package/dist/services/conversation-manager.js.map +1 -1
  30. package/dist/tools/enter-remote-session.d.ts +35 -0
  31. package/dist/tools/enter-remote-session.d.ts.map +1 -1
  32. package/dist/tools/enter-remote-session.js +5 -5
  33. package/dist/tools/enter-remote-session.js.map +1 -1
  34. package/dist/tools/read-binary-file.js +6 -6
  35. package/dist/tools/read-binary-file.js.map +1 -1
  36. package/dist/ui/components/App.d.ts +2 -0
  37. package/dist/ui/components/App.d.ts.map +1 -1
  38. package/dist/ui/components/App.js +105 -95
  39. package/dist/ui/components/App.js.map +1 -1
  40. package/dist/ui/components/ClipboardFileAutocomplete.d.ts +10 -0
  41. package/dist/ui/components/ClipboardFileAutocomplete.d.ts.map +1 -0
  42. package/dist/ui/components/{ClipboardImageAutocomplete.js → ClipboardFileAutocomplete.js} +14 -12
  43. package/dist/ui/components/ClipboardFileAutocomplete.js.map +1 -0
  44. package/dist/ui/components/DetailedPlanReviewScreen.js +1 -1
  45. package/dist/ui/components/DetailedPlanReviewScreen.js.map +1 -1
  46. package/dist/ui/components/InputBox.d.ts +2 -2
  47. package/dist/ui/components/InputBox.d.ts.map +1 -1
  48. package/dist/ui/components/InputBox.js +41 -21
  49. package/dist/ui/components/InputBox.js.map +1 -1
  50. package/dist/ui/components/KeyboardHelp.d.ts.map +1 -1
  51. package/dist/ui/components/KeyboardHelp.js +2 -0
  52. package/dist/ui/components/KeyboardHelp.js.map +1 -1
  53. package/dist/ui/components/MessageDisplay.d.ts +4 -4
  54. package/dist/ui/components/MessageDisplay.d.ts.map +1 -1
  55. package/dist/ui/components/MessageDisplay.js +33 -31
  56. package/dist/ui/components/MessageDisplay.js.map +1 -1
  57. package/dist/ui/components/PlanAcceptedMessage.js +2 -2
  58. package/dist/ui/components/PlanAcceptedMessage.js.map +1 -1
  59. package/dist/ui/components/TaskCompletedMessage.js +2 -2
  60. package/dist/ui/components/TaskCompletedMessage.js.map +1 -1
  61. package/dist/ui/components/ToolExecutionMessage.js +7 -7
  62. package/dist/ui/components/ToolExecutionMessage.js.map +1 -1
  63. package/dist/ui/components/ToolResult.js +1 -1
  64. package/dist/ui/components/ToolResult.js.map +1 -1
  65. package/dist/utils/context-sanitizer.d.ts +50 -0
  66. package/dist/utils/context-sanitizer.d.ts.map +1 -0
  67. package/dist/utils/context-sanitizer.js +119 -0
  68. package/dist/utils/context-sanitizer.js.map +1 -0
  69. package/dist/utils/shell.d.ts.map +1 -1
  70. package/dist/utils/shell.js +12 -1
  71. package/dist/utils/shell.js.map +1 -1
  72. package/dist/utils/syntax-checker.d.ts.map +1 -1
  73. package/dist/utils/syntax-checker.js +26 -0
  74. package/dist/utils/syntax-checker.js.map +1 -1
  75. package/dist/utils/terminal-output.d.ts.map +1 -1
  76. package/dist/utils/terminal-output.js +10 -8
  77. package/dist/utils/terminal-output.js.map +1 -1
  78. package/dist/utils/text-clipboard.d.ts.map +1 -1
  79. package/dist/utils/text-clipboard.js +21 -8
  80. package/dist/utils/text-clipboard.js.map +1 -1
  81. package/package.json +6 -2
  82. package/dist/config/ConfigManager.d.ts +0 -62
  83. package/dist/config/ConfigManager.d.ts.map +0 -1
  84. package/dist/config/ConfigManager.js +0 -234
  85. package/dist/config/ConfigManager.js.map +0 -1
  86. package/dist/ui/components/ClipboardImageAutocomplete.d.ts +0 -14
  87. package/dist/ui/components/ClipboardImageAutocomplete.d.ts.map +0 -1
  88. package/dist/ui/components/ClipboardImageAutocomplete.js.map +0 -1
@@ -3,6 +3,7 @@ import * as path from 'path';
3
3
  import { fileURLToPath } from 'url';
4
4
  import { dirname } from 'path';
5
5
  import * as shellUtils from './utils/shell.js';
6
+ import { sanitizeForContext } from './utils/context-sanitizer.js';
6
7
  const __filename = fileURLToPath(import.meta.url);
7
8
  const __dirname = dirname(__filename);
8
9
  import { ConfigManager } from './config/manager.js';
@@ -128,6 +129,12 @@ export class CentaurusCLI {
128
129
  learnedWorkflowSteps = [];
129
130
  // Callback to set input value (e.g., for revert)
130
131
  onSetInputCallback = null;
132
+ // Interrupt Queue state tracking
133
+ interruptQueue = [];
134
+ onInterruptQueueUpdateCallback;
135
+ onQueuedMessageDispatchedCallback;
136
+ // Track if we are currently reconnecting to remote sessions (prevent race conditions)
137
+ isReconnecting = false;
131
138
  constructor() {
132
139
  this.configManager = new ConfigManager();
133
140
  this.toolRegistry = new ToolRegistry();
@@ -242,6 +249,12 @@ export class CentaurusCLI {
242
249
  setOnWorkflowSave(callback) {
243
250
  this.onWorkflowSaveCallback = callback;
244
251
  }
252
+ setOnInterruptQueueUpdate(callback) {
253
+ this.onInterruptQueueUpdateCallback = callback;
254
+ }
255
+ setOnQueuedMessageDispatched(callback) {
256
+ this.onQueuedMessageDispatchedCallback = callback;
257
+ }
245
258
  setOnAiAutoSuggestChange(callback) {
246
259
  this.onAiAutoSuggestChange = callback;
247
260
  }
@@ -399,10 +412,8 @@ Begin executing now, starting with Step 1.`;
399
412
  }
400
413
  // Check if we are already in a remote session
401
414
  const currentContext = this.contextManager.getCurrentContext();
415
+ // Setup variables for the new context
402
416
  let context;
403
- // Save current CWD to stack BEFORE entering any new session (local or nested)
404
- // This enables proper restoration when exiting, regardless of nesting level
405
- this.cwdStack.push(this.cwd);
406
417
  if (currentContext.type !== 'local') {
407
418
  // Nested session: connect from remote
408
419
  if (detection.handler.connectFromRemote) {
@@ -410,23 +421,18 @@ Begin executing now, starting with Step 1.`;
410
421
  context = await detection.handler.connectFromRemote(command, this.cwd, currentContext);
411
422
  }
412
423
  else {
413
- // If nested connection fails, pop the CWD we just pushed
414
- this.cwdStack.pop();
415
424
  throw new Error(`Nested connections are not supported by the ${type} handler yet.`);
416
425
  }
417
426
  }
418
427
  else {
419
428
  // Local session: connect from local
420
- // For SSH, we shouldn't pass the local (potentially Windows) CWD as it won't exist remotely.
421
- // For local->WSL or local->Docker, the handler might translate it, but strictly speaking
422
- // starting 'fresh' in the remote user's home/default dir is safer for 'warpify' semantics regardless of type.
423
- // However, to avoid regression for WSL/Docker usage where inheritance is expected, we restricts this fix to SSH.
424
429
  const initialCwd = type === 'ssh' ? undefined : this.cwd;
425
- // Use factory method to create a new handler instance for this session
426
- // This prevents state issues where the singleton handler's client is overwritten by nested sessions
427
430
  const newHandler = detection.handler.createNew();
428
431
  context = await newHandler.connect(command, initialCwd);
429
432
  }
433
+ // Connection succeeded! Now we can safely mutate the stacks.
434
+ // Save current CWD to stack BEFORE entering the new session state (local or nested)
435
+ this.cwdStack.push(this.cwd);
430
436
  this.connectionCommandStack.push(command);
431
437
  this.contextManager.pushContext(context);
432
438
  // Explicitly sync this.cwd with the new context's CWD to ensure consistency immediately
@@ -1080,6 +1086,11 @@ Press Enter to continue...
1080
1086
  this.requestIntentionallyAborted = true;
1081
1087
  this.currentAbortController.abort();
1082
1088
  this.currentAbortController = undefined;
1089
+ // Clear interrupt queue on hard cancel
1090
+ this.interruptQueue = [];
1091
+ if (this.onInterruptQueueUpdateCallback) {
1092
+ this.onInterruptQueueUpdateCallback(this.interruptQueue);
1093
+ }
1083
1094
  }
1084
1095
  }
1085
1096
  /**
@@ -1171,6 +1182,13 @@ Press Enter to continue...
1171
1182
  }
1172
1183
  }
1173
1184
  async handleMessage(message, options = {}) {
1185
+ // Prevent messages from being processed while remote reconnection is ongoing
1186
+ if (this.isReconnecting) {
1187
+ if (this.onDirectMessageCallback) {
1188
+ this.onDirectMessageCallback('⏳ Please wait until the chat is fully reconnected before sending a message.');
1189
+ }
1190
+ return;
1191
+ }
1174
1192
  // Handle command mode - execute commands directly
1175
1193
  if (this.commandMode) {
1176
1194
  // Record command step if workflow learning mode is active
@@ -1246,32 +1264,26 @@ Press Enter to continue...
1246
1264
  // Cancel any active request when a new message comes in
1247
1265
  // This enables "interrupt and replace" - new message takes priority
1248
1266
  if (this.currentAbortController) {
1249
- // Mark as intentionally aborted so error handling knows not to throw or show message
1250
- this.requestIntentionallyAborted = true;
1251
- const oldController = this.currentAbortController;
1252
- // Create new controller BEFORE aborting old one to avoid race condition
1253
- // where new request tries to access undefined controller
1254
- this.currentAbortController = new AbortController();
1255
- oldController.abort();
1256
- // Clean up orphaned tool calls from the interrupted turn
1257
- this.cleanupOrphanedToolCalls();
1258
- // Remove the last user message from history (it's being replaced by the new message)
1259
- // Walk backwards and remove messages until we find and remove a user message
1260
- while (this.conversationHistory.length > 0) {
1261
- const lastMsg = this.conversationHistory[this.conversationHistory.length - 1];
1262
- this.conversationHistory.pop();
1263
- if (lastMsg.role === 'user') {
1264
- // Found and removed the interrupted user message, stop here
1265
- break;
1266
- }
1267
- // Continue removing assistant/tool messages that were part of the interrupted turn
1267
+ // INSTEAD of aborting, enqueue the message as an interrupt.
1268
+ const queuedMessage = options.interruptCleanText || message;
1269
+ this.interruptQueue.push(queuedMessage);
1270
+ if (this.onInterruptQueueUpdateCallback) {
1271
+ this.onInterruptQueueUpdateCallback(this.interruptQueue);
1268
1272
  }
1269
- quickLog(`[${new Date().toISOString()}] [handleMessage] Interrupted active request - cleaned up history for replacement\n`);
1273
+ quickLog(`[${new Date().toISOString()}] [handleMessage] Added message to interrupt queue. Queue length: ${this.interruptQueue.length}\n`);
1274
+ return;
1270
1275
  }
1271
1276
  // Store original request if in planning mode (for execution phase after approval)
1272
1277
  if (this.planMode && !this.pendingPlanRequest) {
1273
1278
  this.pendingPlanRequest = message;
1274
1279
  }
1280
+ // Notify UI that this message is now actively being processed.
1281
+ // Only fire for DIRECT (non-queued) messages here.
1282
+ // For queued interrupts, the finally block fires this BEFORE calling handleMessage,
1283
+ // so we must not fire it again here — that would cause the user bubble to appear twice.
1284
+ if (!options.interruptCleanText && this.onQueuedMessageDispatchedCallback) {
1285
+ this.onQueuedMessageDispatchedCallback(message);
1286
+ }
1275
1287
  // Build the user message content - inject plan mode instructions if active
1276
1288
  let userMessageContent = message;
1277
1289
  // When plan mode is active, explicitly inject planning instructions into the user message
@@ -1296,19 +1308,32 @@ DO NOT use write_to_file, edit_file, or execute_command until the plan is approv
1296
1308
  // This clears thinking from the previous agent loop but thinking will be
1297
1309
  // preserved for all turns within the current agent loop
1298
1310
  this.stripThinkingFromHistory();
1299
- // Add user message to history
1311
+ // Add user message to history, using clean text if it's an interrupt
1312
+ const messageToStore = options.interruptCleanText || userMessageContent;
1300
1313
  this.conversationHistory.push({
1301
1314
  role: 'user',
1302
- content: userMessageContent,
1315
+ content: messageToStore,
1303
1316
  });
1317
+ // Capture the abort controller for THIS request locally so we are entirely immune to race conditions
1318
+ if (!this.currentAbortController) {
1319
+ this.currentAbortController = new AbortController();
1320
+ }
1321
+ const myAbortController = this.currentAbortController;
1304
1322
  // Calculate start index for AI context (0 for normal, current index for isolated workflow)
1305
1323
  const contextStartIndex = options.isolatedWorkflow ? this.conversationHistory.length - 1 : 0;
1306
1324
  // Helper to get messages for AI context respecting isolation
1307
1325
  const getMessagesForContext = () => {
1308
- if (options.isolatedWorkflow) {
1309
- return this.conversationHistory.slice(contextStartIndex);
1326
+ const msgs = options.isolatedWorkflow ? this.conversationHistory.slice(contextStartIndex) : [...this.conversationHistory];
1327
+ // If this is an interrupt, we must ensure the AI sees the wrapped message for this specific turn
1328
+ if (options.interruptCleanText && msgs.length > 0) {
1329
+ // Swap the last user message's content with the wrapped message just for the AI request
1330
+ const lastMsg = msgs[msgs.length - 1];
1331
+ if (lastMsg.role === 'user') {
1332
+ // We map over to not mutate the original history array object
1333
+ msgs[msgs.length - 1] = { ...lastMsg, content: userMessageContent };
1334
+ }
1310
1335
  }
1311
- return [...this.conversationHistory];
1336
+ return msgs;
1312
1337
  };
1313
1338
  // Messages are stored locally only - no backend persistence needed
1314
1339
  // Local storage is handled by saveCurrentChat() which saves to ~/.centaurus/chats/
@@ -1555,16 +1580,16 @@ DO NOT use write_to_file, edit_file, or execute_command until the plan is approv
1555
1580
  });
1556
1581
  // Stream AI response from backend
1557
1582
  // Backend will inject system prompt automatically with environment context
1558
- for await (const chunk of aiServiceClient.streamChat(selectedModel, messages, tools, environmentContext, mode, selectedModelThinkingConfig, this.currentAbortController?.signal)) {
1583
+ for await (const chunk of aiServiceClient.streamChat(selectedModel, messages, tools, environmentContext, mode, selectedModelThinkingConfig, myAbortController.signal)) {
1559
1584
  // Handle error chunks
1560
1585
  if (chunk.type === 'error') {
1561
1586
  // Check if this is an abort situation (user cancelled or sent new message)
1562
- if (chunk.code === 'TIMEOUT' && this.requestIntentionallyAborted) {
1563
- // Reset the flag
1564
- this.requestIntentionallyAborted = false;
1565
- // Clean up orphaned tool_calls from conversation history
1566
- // This prevents 400 Bad Request errors when assistant has tool_calls without matching tool results
1567
- this.cleanupOrphanedToolCalls();
1587
+ if (chunk.code === 'TIMEOUT' && myAbortController.isIntentionalAbort) {
1588
+ if (!myAbortController.isReplacement) {
1589
+ // Clean up orphaned tool_calls from conversation history only if NOT a replacement.
1590
+ // If it's a replacement, the newer handleMessage already cleaned up before starting.
1591
+ this.cleanupOrphanedToolCalls();
1592
+ }
1568
1593
  // Gracefully exit - request was intentionally cancelled
1569
1594
  return;
1570
1595
  }
@@ -1711,10 +1736,14 @@ DO NOT use write_to_file, edit_file, or execute_command until the plan is approv
1711
1736
  parsedResult = result.result;
1712
1737
  }
1713
1738
  }
1739
+ // Sanitize context for AI consumption (strip ANSI, compact file writes)
1740
+ const sanitizedResult = sanitizeForContext(toolCall.name, parsedResult, toolCall.arguments);
1741
+ // Log the sanitized version for debugging purposes
1742
+ conversationLogger.logToolResult(`${toolCall.name} (SANITIZED_CONTEXT)`, toolCall.id, sanitizedResult, true);
1714
1743
  inStreamToolResults.push({
1715
1744
  tool_call_id: toolCall.id,
1716
1745
  name: toolCall.name,
1717
- result: this.truncateResult(parsedResult),
1746
+ result: this.truncateResult(sanitizedResult),
1718
1747
  });
1719
1748
  }
1720
1749
  else {
@@ -2207,8 +2236,12 @@ DO NOT use write_to_file, edit_file, or execute_command until the plan is approv
2207
2236
  parsedResult = result.result;
2208
2237
  }
2209
2238
  }
2239
+ // Sanitize context for AI consumption (strip ANSI, compact file writes)
2240
+ const sanitizedResult = sanitizeForContext(toolCall.name, parsedResult, toolCall.arguments);
2241
+ // Log the sanitized version for debugging purposes
2242
+ conversationLogger.logToolResult(`${toolCall.name} (SANITIZED_CONTEXT)`, toolCall.id, sanitizedResult, true);
2210
2243
  // Truncate result before sending to AI (to avoid exceeding message size limits)
2211
- const truncatedResult = this.truncateResult(parsedResult);
2244
+ const truncatedResult = this.truncateResult(sanitizedResult);
2212
2245
  toolResults.push({
2213
2246
  tool_call_id: toolCall.id,
2214
2247
  name: toolCall.name,
@@ -2380,18 +2413,18 @@ DO NOT use write_to_file, edit_file, or execute_command until the plan is approv
2380
2413
  }
2381
2414
  catch (e) { }
2382
2415
  this.conversationHistory.push(assistantHistoryMsg);
2383
- }
2384
- // Add tool results to conversation history as tool messages
2385
- // Format: { tool_call_id, name, result: <object or string> }
2386
- // Only add results for unhandled tool calls (handled ones already added their own results)
2387
- for (const toolResult of unhandledToolResults) {
2388
- // Add tool result to conversation history as tool message
2389
- // IMPORTANT: tool_call_id must be a top-level property
2390
- this.conversationHistory.push({
2391
- role: 'tool',
2392
- tool_call_id: toolResult.tool_call_id,
2393
- content: typeof toolResult.result === 'string' ? toolResult.result : JSON.stringify(toolResult.result),
2394
- });
2416
+ // Add tool results to conversation history as tool messages
2417
+ // Format: { tool_call_id, name, result: <object or string> }
2418
+ // Only add results for unhandled tool calls (handled ones already added their own results)
2419
+ for (const toolResult of unhandledToolResults) {
2420
+ // Add tool result to conversation history as tool message
2421
+ // IMPORTANT: tool_call_id must be a top-level property
2422
+ this.conversationHistory.push({
2423
+ role: 'tool',
2424
+ tool_call_id: toolResult.tool_call_id,
2425
+ content: typeof toolResult.result === 'string' ? toolResult.result : JSON.stringify(toolResult.result),
2426
+ });
2427
+ }
2395
2428
  }
2396
2429
  // Rebuild messages array with updated history
2397
2430
  // During agent loop: keep ALL thinking for current task
@@ -2400,6 +2433,34 @@ DO NOT use write_to_file, edit_file, or execute_command until the plan is approv
2400
2433
  // No need to reset currentTurnThinking - keep accumulating for the task
2401
2434
  // Re-inject subshell context
2402
2435
  messages = this.aiContextInjector.injectSubshellContext(messages, currentContext);
2436
+ // ── Mid-turn interrupt check ──────────────────────────────────
2437
+ // If the user sent a message while this turn was executing, inject
2438
+ // it into the conversation now so the AI sees it on the very next
2439
+ // turn — instead of waiting for the entire multi-turn task to end.
2440
+ if (this.interruptQueue.length > 0) {
2441
+ const nextInterrupt = this.interruptQueue.shift();
2442
+ // Update queue UI
2443
+ if (this.onInterruptQueueUpdateCallback) {
2444
+ this.onInterruptQueueUpdateCallback(this.interruptQueue);
2445
+ }
2446
+ // Notify App.tsx to add the user bubble to messageHistory
2447
+ if (this.onQueuedMessageDispatchedCallback) {
2448
+ this.onQueuedMessageDispatchedCallback(nextInterrupt);
2449
+ }
2450
+ quickLog(`[${new Date().toISOString()}] [handleMessage] Processing mid-turn interrupt from user.\n`);
2451
+ // Wrap the interrupt with context for the AI
2452
+ const wrappedInterrupt = `[SYSTEM NOTE: The user interrupted you with the following message. Please address it, adjust your actions, and proceed with the task.]\n\n${nextInterrupt}`;
2453
+ // Push the interrupt into conversation history so the AI
2454
+ // receives both the tool results from THIS turn and the
2455
+ // user's new message together on the next AI call.
2456
+ this.conversationHistory.push({
2457
+ role: 'user',
2458
+ content: wrappedInterrupt,
2459
+ });
2460
+ // Rebuild messages to include the interrupt
2461
+ messages = getMessagesForContext();
2462
+ messages = this.aiContextInjector.injectSubshellContext(messages, currentContext);
2463
+ }
2403
2464
  continue; // Loop back to AI service
2404
2465
  }
2405
2466
  else {
@@ -2506,11 +2567,10 @@ DO NOT use write_to_file, edit_file, or execute_command until the plan is approv
2506
2567
  // Log the error
2507
2568
  conversationLogger.logError('handleMessage', error);
2508
2569
  // Check if this was an abort/cancellation (including timeout errors from aborted requests)
2509
- if (error.name === 'AbortError' || error.message?.includes('aborted') || error.message?.includes('timed out') || this.requestIntentionallyAborted) {
2570
+ if (error.name === 'AbortError' || error.message?.includes('aborted') || error.message?.includes('timed out') || myAbortController.isIntentionalAbort) {
2510
2571
  // If intentionally aborted for replacement by new message, return silently
2511
2572
  // The new message will take over - no need to show cancellation message
2512
- if (this.requestIntentionallyAborted) {
2513
- this.requestIntentionallyAborted = false;
2573
+ if (myAbortController.isIntentionalAbort && myAbortController.isReplacement) {
2514
2574
  return;
2515
2575
  }
2516
2576
  conversationLogger.logError('handleMessage', new Error('Request cancelled by user'));
@@ -2535,6 +2595,29 @@ DO NOT use write_to_file, edit_file, or execute_command until the plan is approv
2535
2595
  }
2536
2596
  // Clean up abort controller
2537
2597
  this.currentAbortController = undefined;
2598
+ // Process any queued interrupts if the task wasn't aborted intentionally
2599
+ if (this.interruptQueue.length > 0 && !this.requestIntentionallyAborted) {
2600
+ // We aren't checking `myAbortController.isIntentionalAbort` here because we don't set it anymore
2601
+ // Next message in queue
2602
+ const nextInterrupt = this.interruptQueue.shift();
2603
+ if (this.onInterruptQueueUpdateCallback) {
2604
+ this.onInterruptQueueUpdateCallback(this.interruptQueue);
2605
+ }
2606
+ // Notify UI that this queued message is now being dispatched for processing.
2607
+ // App.tsx uses this to add the user message to messageHistory at the correct time
2608
+ // (i.e., when the message actually starts being processed, not when it was first queued).
2609
+ if (this.onQueuedMessageDispatchedCallback) {
2610
+ this.onQueuedMessageDispatchedCallback(nextInterrupt);
2611
+ }
2612
+ quickLog(`[${new Date().toISOString()}] [handleMessage] Processing queued interrupt from user.\n`);
2613
+ const wrappedMessage = `[SYSTEM NOTE: The user interrupted you with the following message. Please address it, adjust your actions, and proceed with the task.]\n\n${nextInterrupt}`;
2614
+ // Start next message processing asynchronously so we don't block finally block returning
2615
+ setTimeout(() => {
2616
+ this.handleMessage(wrappedMessage, { interruptCleanText: nextInterrupt }).catch(err => {
2617
+ conversationLogger.logError('Queued interrupt handleMessage', err);
2618
+ });
2619
+ }, 0);
2620
+ }
2538
2621
  }
2539
2622
  }
2540
2623
  async handleSlashCommand(command) {
@@ -3632,7 +3715,12 @@ Create once, run many times across different machines.`;
3632
3715
  const workflowPrompt = this.buildWorkflowPrompt(workflow);
3633
3716
  // Use setTimeout to allow the UI to update before starting execution
3634
3717
  setTimeout(() => {
3635
- this.handleMessage(workflowPrompt, { isolatedWorkflow: true });
3718
+ this.handleMessage(workflowPrompt, { isolatedWorkflow: true }).catch((err) => {
3719
+ quickLog(`[${new Date().toISOString()}] [Workflow] Error executing workflow: ${err.message}\n`);
3720
+ if (this.onDirectMessageCallback) {
3721
+ this.onDirectMessageCallback(`❌ Error executing workflow: ${err.message}`);
3722
+ }
3723
+ });
3636
3724
  }, 100);
3637
3725
  return;
3638
3726
  }
@@ -4447,116 +4535,122 @@ Create once, run many times across different machines.`;
4447
4535
  if (this.onDirectMessageCallback) {
4448
4536
  this.onDirectMessageCallback(`🔄 Reconnecting to session${nestingInfo}...`);
4449
4537
  }
4450
- // Sequential reconnection through the stack
4451
- let previousCwd = baseLocalCwd;
4452
- for (let i = 0; i < contextStackToRestore.length; i++) {
4453
- const remoteCtx = contextStackToRestore[i];
4454
- const { type, connectionCommand, remoteCwd } = remoteCtx;
4455
- const levelInfo = contextStackToRestore.length > 1 ? ` [${i + 1}/${contextStackToRestore.length}]` : '';
4456
- // Show connecting status for this level
4457
- if (this.onDirectMessageCallback) {
4458
- this.onDirectMessageCallback(`🔄${levelInfo} Connecting to ${type.toUpperCase()}...`);
4459
- }
4460
- if (this.onConnectionStatusUpdate) {
4461
- this.onConnectionStatusUpdate({
4462
- type: type,
4463
- status: 'connecting',
4464
- connectionString: this.buildConnectionString(type, remoteCtx.metadata)
4465
- });
4466
- }
4467
- try {
4468
- // Detect and connect using the saved command
4469
- const detection = this.commandDetector.detect(connectionCommand);
4470
- if (!detection) {
4471
- throw new Error(`Could not detect handler for: ${connectionCommand}`);
4472
- }
4473
- // Create a new instance of the handler to ensure each level of the connection
4474
- // has its own state (client, stream, etc.), critical for nested sessions of the same type
4475
- const handler = detection.handler.createNew();
4476
- let context;
4477
- // Check if this is a nested connection (i > 0 means we're inside a remote session)
4478
- if (i > 0) {
4479
- const currentContext = this.contextManager.getCurrentContext();
4480
- if (handler.connectFromRemote) {
4481
- context = await handler.connectFromRemote(connectionCommand, previousCwd, currentContext);
4482
- }
4483
- else {
4484
- throw new Error(`Nested connections not supported by ${type} handler`);
4485
- }
4486
- }
4487
- else {
4488
- // First level: connect from local
4489
- context = await handler.connect(connectionCommand, previousCwd);
4490
- }
4491
- // Push to stacks
4492
- this.connectionCommandStack.push(connectionCommand);
4493
- if (i > 0) {
4494
- this.cwdStack.push(previousCwd); // Save the previous remote CWD for nested exits
4495
- }
4496
- this.contextManager.pushContext(context);
4497
- // Navigate to saved remote CWD
4498
- if (remoteCwd && remoteCwd !== context.metadata.workingDirectory) {
4499
- try {
4500
- await this.contextManager.executeCommand(`cd "${remoteCwd}"`);
4501
- }
4502
- catch (cdError) {
4503
- // Failed to cd to saved path - warn but continue
4504
- if (this.onDirectMessageCallback) {
4505
- this.onDirectMessageCallback(`⚠️ Could not restore ${type} directory: ${remoteCwd}`);
4506
- }
4507
- }
4538
+ this.isReconnecting = true;
4539
+ try {
4540
+ // Sequential reconnection through the stack
4541
+ let previousCwd = baseLocalCwd;
4542
+ for (let i = 0; i < contextStackToRestore.length; i++) {
4543
+ const remoteCtx = contextStackToRestore[i];
4544
+ const { type, connectionCommand, remoteCwd } = remoteCtx;
4545
+ const levelInfo = contextStackToRestore.length > 1 ? ` [${i + 1}/${contextStackToRestore.length}]` : '';
4546
+ // Show connecting status for this level
4547
+ if (this.onDirectMessageCallback) {
4548
+ this.onDirectMessageCallback(`🔄${levelInfo} Connecting to ${type.toUpperCase()}...`);
4508
4549
  }
4509
- // Update previousCwd for next iteration
4510
- previousCwd = this.contextManager.getCurrentContext().metadata.workingDirectory;
4511
- // Show success for this level
4512
4550
  if (this.onConnectionStatusUpdate) {
4513
4551
  this.onConnectionStatusUpdate({
4514
4552
  type: type,
4515
- status: 'connected',
4553
+ status: 'connecting',
4516
4554
  connectionString: this.buildConnectionString(type, remoteCtx.metadata)
4517
4555
  });
4518
4556
  }
4519
- }
4520
- catch (error) {
4521
- // Connection failed at this level - fall back to local mode
4522
- // If we partially connected, disconnect all and reset
4523
- while (this.contextManager.getCurrentContext().type !== 'local') {
4524
- const ctx = this.contextManager.getCurrentContext();
4525
- if (ctx.handler) {
4557
+ try {
4558
+ // Detect and connect using the saved command
4559
+ const detection = this.commandDetector.detect(connectionCommand);
4560
+ if (!detection) {
4561
+ throw new Error(`Could not detect handler for: ${connectionCommand}`);
4562
+ }
4563
+ // Create a new instance of the handler to ensure each level of the connection
4564
+ // has its own state (client, stream, etc.), critical for nested sessions of the same type
4565
+ const handler = detection.handler.createNew();
4566
+ let context;
4567
+ // Check if this is a nested connection (i > 0 means we're inside a remote session)
4568
+ if (i > 0) {
4569
+ const currentContext = this.contextManager.getCurrentContext();
4570
+ if (handler.connectFromRemote) {
4571
+ context = await handler.connectFromRemote(connectionCommand, previousCwd, currentContext);
4572
+ }
4573
+ else {
4574
+ throw new Error(`Nested connections not supported by ${type} handler`);
4575
+ }
4576
+ }
4577
+ else {
4578
+ // First level: connect from local
4579
+ context = await handler.connect(connectionCommand, previousCwd);
4580
+ }
4581
+ // Push to stacks
4582
+ this.connectionCommandStack.push(connectionCommand);
4583
+ if (i > 0) {
4584
+ this.cwdStack.push(previousCwd); // Save the previous remote CWD for nested exits
4585
+ }
4586
+ this.contextManager.pushContext(context);
4587
+ // Navigate to saved remote CWD
4588
+ if (remoteCwd && remoteCwd !== context.metadata.workingDirectory) {
4526
4589
  try {
4527
- await ctx.handler.disconnect();
4590
+ await this.contextManager.executeCommand(`cd "${remoteCwd}"`);
4528
4591
  }
4529
- catch (e) { /* ignore */ }
4592
+ catch (cdError) {
4593
+ // Failed to cd to saved path - warn but continue
4594
+ if (this.onDirectMessageCallback) {
4595
+ this.onDirectMessageCallback(`⚠️ Could not restore ${type} directory: ${remoteCwd}`);
4596
+ }
4597
+ }
4598
+ }
4599
+ // Update previousCwd for next iteration
4600
+ previousCwd = this.contextManager.getCurrentContext().metadata.workingDirectory;
4601
+ // Show success for this level
4602
+ if (this.onConnectionStatusUpdate) {
4603
+ this.onConnectionStatusUpdate({
4604
+ type: type,
4605
+ status: 'connected',
4606
+ connectionString: this.buildConnectionString(type, remoteCtx.metadata)
4607
+ });
4530
4608
  }
4531
- this.contextManager.popContext();
4532
4609
  }
4533
- this.cwdStack = [];
4534
- this.connectionCommandStack = [];
4535
- if (this.onConnectionStatusUpdate) {
4536
- this.onConnectionStatusUpdate({
4537
- type: type,
4538
- status: 'error',
4539
- connectionString: this.buildConnectionString(type, remoteCtx.metadata),
4540
- error: error.message
4541
- });
4610
+ catch (error) {
4611
+ // Connection failed at this level - fall back to local mode
4612
+ // If we partially connected, disconnect all and reset
4613
+ while (this.contextManager.getCurrentContext().type !== 'local') {
4614
+ const ctx = this.contextManager.getCurrentContext();
4615
+ if (ctx.handler) {
4616
+ try {
4617
+ await ctx.handler.disconnect();
4618
+ }
4619
+ catch (e) { /* ignore */ }
4620
+ }
4621
+ this.contextManager.popContext();
4622
+ }
4623
+ this.cwdStack = [];
4624
+ this.connectionCommandStack = [];
4625
+ if (this.onConnectionStatusUpdate) {
4626
+ this.onConnectionStatusUpdate({
4627
+ type: type,
4628
+ status: 'error',
4629
+ connectionString: this.buildConnectionString(type, remoteCtx.metadata),
4630
+ error: error.message
4631
+ });
4632
+ }
4633
+ const failedAt = contextStackToRestore.length > 1 ? ` at level ${i + 1} (${type})` : '';
4634
+ if (this.onDirectMessageCallback) {
4635
+ this.onDirectMessageCallback(`⚠️ Loaded chat: "${chat.title}"\n\n❌ Could not reconnect${failedAt}: ${error.message}\n\n📁 Restored to local directory: ${baseLocalCwd}`);
4636
+ }
4637
+ return;
4542
4638
  }
4543
- const failedAt = contextStackToRestore.length > 1 ? ` at level ${i + 1} (${type})` : '';
4544
- if (this.onDirectMessageCallback) {
4545
- this.onDirectMessageCallback(`⚠️ Loaded chat: "${chat.title}"\n\n❌ Could not reconnect${failedAt}: ${error.message}\n\n📁 Restored to local directory: ${baseLocalCwd}`);
4639
+ }
4640
+ // All levels connected successfully
4641
+ if (this.onDirectMessageCallback) {
4642
+ const successInfo = contextStackToRestore.length > 1
4643
+ ? `🔗 Reconnected through ${contextStackToRestore.length} levels`
4644
+ : '';
4645
+ if (successInfo) {
4646
+ this.onDirectMessageCallback(successInfo);
4546
4647
  }
4547
- return;
4548
4648
  }
4649
+ return;
4549
4650
  }
4550
- // All levels connected successfully
4551
- if (this.onDirectMessageCallback) {
4552
- const successInfo = contextStackToRestore.length > 1
4553
- ? `🔗 Reconnected through ${contextStackToRestore.length} levels`
4554
- : '';
4555
- if (successInfo) {
4556
- this.onDirectMessageCallback(successInfo);
4557
- }
4651
+ finally {
4652
+ this.isReconnecting = false;
4558
4653
  }
4559
- return;
4560
4654
  }
4561
4655
  // No remote context - show regular confirmation and restore CWD
4562
4656
  if (!shouldPreserveRemote && chat.cwd && !chat.remoteContext) {