centaurus-cli 2.9.3 → 2.9.5

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 (110) hide show
  1. package/dist/cli-adapter.d.ts +74 -10
  2. package/dist/cli-adapter.d.ts.map +1 -1
  3. package/dist/cli-adapter.js +898 -244
  4. package/dist/cli-adapter.js.map +1 -1
  5. package/dist/commands/CommandParser.d.ts +1 -1
  6. package/dist/commands/CommandParser.d.ts.map +1 -1
  7. package/dist/commands/CommandParser.js +113 -0
  8. package/dist/commands/CommandParser.js.map +1 -1
  9. package/dist/config/slash-commands.d.ts +2 -0
  10. package/dist/config/slash-commands.d.ts.map +1 -1
  11. package/dist/config/slash-commands.js +28 -0
  12. package/dist/config/slash-commands.js.map +1 -1
  13. package/dist/context/context-manager.d.ts +7 -1
  14. package/dist/context/context-manager.d.ts.map +1 -1
  15. package/dist/context/context-manager.js +14 -1
  16. package/dist/context/context-manager.js.map +1 -1
  17. package/dist/context/handlers/docker-handler.d.ts +11 -0
  18. package/dist/context/handlers/docker-handler.d.ts.map +1 -1
  19. package/dist/context/handlers/docker-handler.js +159 -14
  20. package/dist/context/handlers/docker-handler.js.map +1 -1
  21. package/dist/context/handlers/ssh-handler.d.ts +20 -0
  22. package/dist/context/handlers/ssh-handler.d.ts.map +1 -1
  23. package/dist/context/handlers/ssh-handler.js +129 -1
  24. package/dist/context/handlers/ssh-handler.js.map +1 -1
  25. package/dist/context/subshell-handler.d.ts +15 -0
  26. package/dist/context/subshell-handler.d.ts.map +1 -1
  27. package/dist/index.js +10 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/services/ai-service-client.d.ts.map +1 -1
  30. package/dist/services/ai-service-client.js +33 -11
  31. package/dist/services/ai-service-client.js.map +1 -1
  32. package/dist/services/api-client.js +1 -1
  33. package/dist/services/api-client.js.map +1 -1
  34. package/dist/services/local-chat-storage.d.ts +3 -1
  35. package/dist/services/local-chat-storage.d.ts.map +1 -1
  36. package/dist/services/local-chat-storage.js +8 -3
  37. package/dist/services/local-chat-storage.js.map +1 -1
  38. package/dist/services/warpify-detector.d.ts +43 -0
  39. package/dist/services/warpify-detector.d.ts.map +1 -0
  40. package/dist/services/warpify-detector.js +203 -0
  41. package/dist/services/warpify-detector.js.map +1 -0
  42. package/dist/services/workflow-storage.d.ts +72 -0
  43. package/dist/services/workflow-storage.d.ts.map +1 -0
  44. package/dist/services/workflow-storage.js +239 -0
  45. package/dist/services/workflow-storage.js.map +1 -0
  46. package/dist/tools/command.d.ts.map +1 -1
  47. package/dist/tools/command.js +106 -38
  48. package/dist/tools/command.js.map +1 -1
  49. package/dist/tools/enter-remote-session.d.ts +13 -0
  50. package/dist/tools/enter-remote-session.d.ts.map +1 -0
  51. package/dist/tools/enter-remote-session.js +226 -0
  52. package/dist/tools/enter-remote-session.js.map +1 -0
  53. package/dist/tools/find-files.d.ts.map +1 -1
  54. package/dist/tools/find-files.js +9 -2
  55. package/dist/tools/find-files.js.map +1 -1
  56. package/dist/tools/grep-search.d.ts +104 -31
  57. package/dist/tools/grep-search.d.ts.map +1 -1
  58. package/dist/tools/grep-search.js +779 -431
  59. package/dist/tools/grep-search.js.map +1 -1
  60. package/dist/tools/workflow-tool.d.ts +11 -0
  61. package/dist/tools/workflow-tool.d.ts.map +1 -0
  62. package/dist/tools/workflow-tool.js +87 -0
  63. package/dist/tools/workflow-tool.js.map +1 -0
  64. package/dist/types/workflow.d.ts +110 -0
  65. package/dist/types/workflow.d.ts.map +1 -0
  66. package/dist/types/workflow.js +8 -0
  67. package/dist/types/workflow.js.map +1 -0
  68. package/dist/ui/components/App.d.ts +10 -1
  69. package/dist/ui/components/App.d.ts.map +1 -1
  70. package/dist/ui/components/App.js +135 -8
  71. package/dist/ui/components/App.js.map +1 -1
  72. package/dist/ui/components/Breadcrumbs.d.ts +4 -3
  73. package/dist/ui/components/Breadcrumbs.d.ts.map +1 -1
  74. package/dist/ui/components/Breadcrumbs.js +80 -54
  75. package/dist/ui/components/Breadcrumbs.js.map +1 -1
  76. package/dist/ui/components/ConnectionStatusMessage.js +2 -2
  77. package/dist/ui/components/ConnectionStatusMessage.js.map +1 -1
  78. package/dist/ui/components/InputBox.d.ts +1 -0
  79. package/dist/ui/components/InputBox.d.ts.map +1 -1
  80. package/dist/ui/components/InputBox.js +226 -19
  81. package/dist/ui/components/InputBox.js.map +1 -1
  82. package/dist/ui/components/InteractiveShell.d.ts +4 -0
  83. package/dist/ui/components/InteractiveShell.d.ts.map +1 -1
  84. package/dist/ui/components/InteractiveShell.js +52 -15
  85. package/dist/ui/components/InteractiveShell.js.map +1 -1
  86. package/dist/ui/components/KeyboardHelp.d.ts.map +1 -1
  87. package/dist/ui/components/KeyboardHelp.js +14 -6
  88. package/dist/ui/components/KeyboardHelp.js.map +1 -1
  89. package/dist/ui/components/ToolExecutionMessage.d.ts.map +1 -1
  90. package/dist/ui/components/ToolExecutionMessage.js +165 -27
  91. package/dist/ui/components/ToolExecutionMessage.js.map +1 -1
  92. package/dist/ui/components/WorkflowCreatorScreen.d.ts +25 -0
  93. package/dist/ui/components/WorkflowCreatorScreen.d.ts.map +1 -0
  94. package/dist/ui/components/WorkflowCreatorScreen.js +164 -0
  95. package/dist/ui/components/WorkflowCreatorScreen.js.map +1 -0
  96. package/dist/utils/ansi-encoder.d.ts.map +1 -1
  97. package/dist/utils/ansi-encoder.js +7 -0
  98. package/dist/utils/ansi-encoder.js.map +1 -1
  99. package/dist/utils/editor-utils.d.ts +9 -0
  100. package/dist/utils/editor-utils.d.ts.map +1 -1
  101. package/dist/utils/editor-utils.js +105 -0
  102. package/dist/utils/editor-utils.js.map +1 -1
  103. package/dist/utils/input-classifier.d.ts.map +1 -1
  104. package/dist/utils/input-classifier.js +2 -1
  105. package/dist/utils/input-classifier.js.map +1 -1
  106. package/dist/utils/terminal-output.d.ts +3 -1
  107. package/dist/utils/terminal-output.d.ts.map +1 -1
  108. package/dist/utils/terminal-output.js +138 -157
  109. package/dist/utils/terminal-output.js.map +1 -1
  110. package/package.json +1 -1
@@ -20,6 +20,8 @@ import { readBinaryFileTool } from './tools/read-binary-file.js';
20
20
  import { createImageTool } from './tools/create-image.js';
21
21
  import { backgroundCommandTool } from './tools/background-command.js';
22
22
  import { subAgentTool } from './tools/sub-agent.js';
23
+ import { enterRemoteSessionTool } from './tools/enter-remote-session.js';
24
+ import { workflowTool } from './tools/workflow-tool.js';
23
25
  import { SubAgentManager } from './services/sub-agent-manager.js';
24
26
  import { ShellInputAgent } from './services/shell-input-agent.js';
25
27
  import { apiClient } from './services/api-client.js';
@@ -43,6 +45,7 @@ import { logWarning } from './utils/logger.js';
43
45
  import { BackgroundTaskManager } from './services/background-task-manager.js';
44
46
  import { sessionQuotaManager } from './services/session-quota-manager.js';
45
47
  import { ollamaService, OllamaService } from './services/ollama-service.js';
48
+ import { workflowStorage } from './services/workflow-storage.js';
46
49
  export class CentaurusCLI {
47
50
  configManager;
48
51
  toolRegistry;
@@ -90,8 +93,8 @@ export class CentaurusCLI {
90
93
  onShowChatRenamePickerCallback;
91
94
  onRestoreMessagesCallback;
92
95
  uiMessageHistory = []; // Mirror of App.tsx's messageHistory for saving
93
- localCwdBeforeRemote = null; // Track local CWD before entering remote session
94
- lastConnectionCommand = null; // Track the command used to connect to remote
96
+ cwdStack = []; // Stack of CWDs for nested sessions (pushed when entering, popped when exiting)
97
+ connectionCommandStack = []; // Stack of commands used to connect (for nested sessions like SSH>SSH, SSH>Docker)
95
98
  onBackgroundModeChange;
96
99
  onBackgroundTaskCountChange;
97
100
  onSetAutoMode;
@@ -111,6 +114,11 @@ export class CentaurusCLI {
111
114
  onShowMCPListScreen;
112
115
  onSubAgentCountChange; // Callback for sub-agent count changes
113
116
  onPromptAnswered; // Callback when AI answers a shell prompt
117
+ onShowWorkflowCreatorCallback; // Callback to show workflow creator screen with optional initial steps
118
+ onWorkflowSaveCallback; // Callback when workflow is saved
119
+ // Workflow learning mode state
120
+ workflowLearningActive = false;
121
+ learnedWorkflowSteps = [];
114
122
  constructor() {
115
123
  this.configManager = new ConfigManager();
116
124
  this.toolRegistry = new ToolRegistry();
@@ -120,13 +128,14 @@ export class CentaurusCLI {
120
128
  this.commandDetector = new CommandDetector();
121
129
  this.aiContextInjector = new AIContextInjector();
122
130
  // Register context change callback to update cwd
123
- this.contextManager.onContextChange((context) => {
131
+ // Register context change callback to update cwd
132
+ this.contextManager.onContextChange((context, stack) => {
124
133
  this.cwd = context.metadata.workingDirectory;
125
134
  if (this.onCwdChange) {
126
135
  this.onCwdChange(this.cwd);
127
136
  }
128
137
  if (this.onSubshellContextChange) {
129
- this.onSubshellContextChange(context);
138
+ this.onSubshellContextChange(context, stack);
130
139
  }
131
140
  });
132
141
  // Initialize MCP
@@ -218,6 +227,355 @@ export class CentaurusCLI {
218
227
  // Wire this callback to ShellInputAgent
219
228
  ShellInputAgent.setOnPromptAnswered(callback);
220
229
  }
230
+ setOnShowWorkflowCreator(callback) {
231
+ this.onShowWorkflowCreatorCallback = callback;
232
+ }
233
+ setOnWorkflowSave(callback) {
234
+ this.onWorkflowSaveCallback = callback;
235
+ }
236
+ /**
237
+ * Save a workflow from the workflow creator UI
238
+ */
239
+ saveWorkflow(name, steps, description) {
240
+ const workflow = workflowStorage.createWorkflow(name, steps, description);
241
+ workflowStorage.save(workflow);
242
+ if (this.onWorkflowSaveCallback) {
243
+ this.onWorkflowSaveCallback(workflow);
244
+ }
245
+ }
246
+ /**
247
+ * Toggle workflow learning mode.
248
+ * First call: Start learning and return a message to display.
249
+ * Second call: Stop learning and show workflow creator with learned steps.
250
+ * @returns Message to display to user, or null if showing workflow creator
251
+ */
252
+ toggleWorkflowLearning() {
253
+ if (this.workflowLearningActive) {
254
+ // Stop learning mode
255
+ this.workflowLearningActive = false;
256
+ const steps = [...this.learnedWorkflowSteps];
257
+ this.learnedWorkflowSteps = []; // Clear for next learning session
258
+ if (steps.length === 0) {
259
+ return '⚠️ No steps were recorded. Learning mode cancelled.';
260
+ }
261
+ // Show workflow creator with the learned steps
262
+ if (this.onShowWorkflowCreatorCallback) {
263
+ this.onShowWorkflowCreatorCallback(steps);
264
+ return null; // UI will handle the screen change
265
+ }
266
+ else {
267
+ return '❌ Workflow creator not available. Please update the CLI.';
268
+ }
269
+ }
270
+ else {
271
+ // Start learning mode
272
+ this.workflowLearningActive = true;
273
+ this.learnedWorkflowSteps = [];
274
+ return `📚 **Learning mode started!**
275
+
276
+ Commands and prompts from here will be recorded to create your workflow.
277
+
278
+ **Instructions:**
279
+ • Run commands normally - they'll be saved as command steps
280
+ • Type AI prompts - they'll be saved as instruction steps
281
+ • Run \`/workflow new learn-workflow\` again when you're done
282
+
283
+ Recording will begin with your next input.`;
284
+ }
285
+ }
286
+ /**
287
+ * Check if workflow learning is currently active
288
+ */
289
+ isWorkflowLearningActive() {
290
+ return this.workflowLearningActive;
291
+ }
292
+ /**
293
+ * Record a step during workflow learning mode
294
+ * @param type Type of step (command or instruction)
295
+ * @param content The command or instruction content
296
+ */
297
+ recordWorkflowStep(type, content) {
298
+ if (this.workflowLearningActive && content.trim()) {
299
+ this.learnedWorkflowSteps.push({ type, content: content.trim() });
300
+ quickLog(`[${new Date().toISOString()}] [WorkflowLearning] Recorded ${type}: ${content.trim()}\n`);
301
+ }
302
+ }
303
+ /**
304
+ * Cancel workflow learning mode with a message
305
+ * Used when conflicting commands are run during learning (e.g., /exit, /clear, /workflow run)
306
+ * @param reason The reason for cancellation
307
+ * @returns Message to display to user, or empty string if not in learning mode
308
+ */
309
+ cancelWorkflowLearning(reason) {
310
+ if (!this.workflowLearningActive) {
311
+ return '';
312
+ }
313
+ this.workflowLearningActive = false;
314
+ const stepsCount = this.learnedWorkflowSteps.length;
315
+ this.learnedWorkflowSteps = [];
316
+ quickLog(`[${new Date().toISOString()}] [WorkflowLearning] Cancelled: ${reason} (${stepsCount} steps discarded)\n`);
317
+ return `⚠️ **Learning mode cancelled**: ${reason}\n\n` +
318
+ `${stepsCount > 0 ? `${stepsCount} recorded step(s) were discarded.` : 'No steps were recorded.'}\n` +
319
+ `Run \`/workflow new learn-workflow\` to start again.`;
320
+ }
321
+ /**
322
+ * Build a prompt for the AI to execute a workflow
323
+ * This formats the workflow steps into a clear instruction set
324
+ */
325
+ buildWorkflowPrompt(workflow) {
326
+ const stepsText = workflow.steps.map((step, index) => {
327
+ const stepNum = index + 1;
328
+ if (step.type === 'command') {
329
+ return `Step ${stepNum}: [RUN COMMAND] ${step.content}`;
330
+ }
331
+ else {
332
+ return `Step ${stepNum}: [INSTRUCTION] ${step.content}`;
333
+ }
334
+ }).join('\n');
335
+ return `Execute the following workflow "${workflow.name}" step by step:
336
+
337
+ ${stepsText}
338
+
339
+ IMPORTANT:
340
+ - For [RUN COMMAND] steps, execute the exact command shown using the execute_command tool.
341
+ - For [INSTRUCTION] steps, follow the instruction using appropriate tools.
342
+ - Execute each step in order, waiting for completion before moving to the next.
343
+ - If any step fails, stop and report the error.
344
+ - After all steps complete successfully, summarize what was done.
345
+
346
+ Begin executing now, starting with Step 1.`;
347
+ }
348
+ /**
349
+ * Warpify a detected SSH/WSL/Docker session.
350
+ * This establishes a proper ssh2/handler connection (will prompt for password)
351
+ * so commands can be executed via the handler, not just PTY passthrough.
352
+ *
353
+ * @param command - The original command (e.g., "ssh rohan@localhost")
354
+ * @param type - The detected session type
355
+ * @param connectionString - The connection string (e.g., "rohan@localhost")
356
+ * @returns Promise<boolean> - true if warpify succeeded
357
+ */
358
+ async warpifySession(command, type, connectionString) {
359
+ try {
360
+ // Show connecting status
361
+ if (this.onConnectionStatusUpdate) {
362
+ this.onConnectionStatusUpdate({
363
+ type: type,
364
+ status: 'connecting',
365
+ connectionString: connectionString
366
+ });
367
+ }
368
+ // Detect and connect using the command
369
+ const detection = this.commandDetector.detect(command);
370
+ if (!detection) {
371
+ throw new Error(`Could not detect handler for command: ${command}`);
372
+ }
373
+ // Check if we are already in a remote session
374
+ const currentContext = this.contextManager.getCurrentContext();
375
+ let context;
376
+ // Save current CWD to stack BEFORE entering any new session (local or nested)
377
+ // This enables proper restoration when exiting, regardless of nesting level
378
+ this.cwdStack.push(this.cwd);
379
+ if (currentContext.type !== 'local') {
380
+ // Nested session: connect from remote
381
+ if (detection.handler.connectFromRemote) {
382
+ quickLog(`[${new Date().toISOString()}] [Warpify] nesting connection: ${currentContext.type} -> ${type}\n`);
383
+ context = await detection.handler.connectFromRemote(command, this.cwd, currentContext);
384
+ }
385
+ else {
386
+ // If nested connection fails, pop the CWD we just pushed
387
+ this.cwdStack.pop();
388
+ throw new Error(`Nested connections are not supported by the ${type} handler yet.`);
389
+ }
390
+ }
391
+ else {
392
+ // Local session: connect from local
393
+ // For SSH, we shouldn't pass the local (potentially Windows) CWD as it won't exist remotely.
394
+ // For local->WSL or local->Docker, the handler might translate it, but strictly speaking
395
+ // starting 'fresh' in the remote user's home/default dir is safer for 'warpify' semantics regardless of type.
396
+ // However, to avoid regression for WSL/Docker usage where inheritance is expected, we restricts this fix to SSH.
397
+ const initialCwd = type === 'ssh' ? undefined : this.cwd;
398
+ context = await detection.handler.connect(command, initialCwd);
399
+ }
400
+ this.connectionCommandStack.push(command);
401
+ this.contextManager.pushContext(context);
402
+ // Explicitly sync this.cwd with the new context's CWD to ensure consistency immediately
403
+ if (context.metadata.workingDirectory) {
404
+ this.cwd = context.metadata.workingDirectory;
405
+ }
406
+ // Set up disconnect callback for remote connections
407
+ if (context.handler && context.handler.setDisconnectCallback) {
408
+ context.handler.setDisconnectCallback((error) => {
409
+ this.handleRemoteDisconnect(connectionString || '', type, error);
410
+ });
411
+ }
412
+ // Show success
413
+ if (this.onConnectionStatusUpdate) {
414
+ this.onConnectionStatusUpdate({
415
+ type: type,
416
+ status: 'connected',
417
+ connectionString: connectionString
418
+ });
419
+ }
420
+ // Update CWD to remote working directory
421
+ if (this.onCwdChange && context.metadata.workingDirectory) {
422
+ this.onCwdChange(context.metadata.workingDirectory);
423
+ }
424
+ quickLog(`[${new Date().toISOString()}] [Warpify] Successfully established ${type} connection via ssh2/handler\n`);
425
+ return true;
426
+ }
427
+ catch (error) {
428
+ // Connection failed
429
+ if (this.onConnectionStatusUpdate) {
430
+ this.onConnectionStatusUpdate({
431
+ type: type,
432
+ status: 'error',
433
+ connectionString: connectionString,
434
+ error: error.message
435
+ });
436
+ }
437
+ quickLog(`[${new Date().toISOString()}] [Warpify] Failed to establish connection: ${error.message}\n`);
438
+ return false;
439
+ }
440
+ }
441
+ /**
442
+ * Exit the current remote session and return to parent environment.
443
+ * Called by the AI agent's enter_remote_session tool with action="exit".
444
+ * @returns Promise<boolean> - true if there was a session to exit
445
+ */
446
+ async exitRemoteSession() {
447
+ const currentContext = this.contextManager.getCurrentContext();
448
+ // Check if we're in a remote session
449
+ if (currentContext.type === 'local') {
450
+ return false; // No remote session to exit
451
+ }
452
+ // Get connection info for logging
453
+ const sessionType = currentContext.type;
454
+ const metadata = currentContext.metadata;
455
+ const connectionString = metadata.connectionString ||
456
+ metadata.host ||
457
+ metadata.containerId ||
458
+ metadata.distro ||
459
+ metadata.workingDirectory ||
460
+ 'unknown';
461
+ quickLog(`[${new Date().toISOString()}] [ExitRemoteSession] Exiting ${sessionType} session: ${connectionString}\n`);
462
+ // Close the handler if it has a disconnect method
463
+ if (currentContext.handler && typeof currentContext.handler.disconnect === 'function') {
464
+ try {
465
+ await currentContext.handler.disconnect();
466
+ }
467
+ catch (error) {
468
+ quickLog(`[${new Date().toISOString()}] [ExitRemoteSession] Error during handler disconnect: ${error.message}\n`);
469
+ }
470
+ }
471
+ // Pop the context
472
+ this.contextManager.popContext();
473
+ // Restore CWD from stack
474
+ const previousCwd = this.cwdStack.pop();
475
+ const newContext = this.contextManager.getCurrentContext();
476
+ if (previousCwd) {
477
+ this.cwd = previousCwd;
478
+ }
479
+ else if (newContext.type !== 'local') {
480
+ this.cwd = newContext.metadata.workingDirectory;
481
+ }
482
+ this.contextManager.updateWorkingDirectory(this.cwd);
483
+ if (newContext.type === 'local') {
484
+ this.connectionCommandStack.pop();
485
+ }
486
+ // Notify CWD change
487
+ if (this.onCwdChange) {
488
+ this.onCwdChange(this.cwd);
489
+ }
490
+ // Save chat state
491
+ this.saveCurrentChat();
492
+ // Update UI connection status
493
+ if (this.onConnectionStatusUpdate) {
494
+ if (newContext.type === 'local') {
495
+ this.onConnectionStatusUpdate({
496
+ type: sessionType,
497
+ status: 'disconnected',
498
+ connectionString: connectionString
499
+ });
500
+ }
501
+ else {
502
+ // Still in a nested session
503
+ const newMetadata = newContext.metadata;
504
+ this.onConnectionStatusUpdate({
505
+ type: newContext.type,
506
+ status: 'connected',
507
+ connectionString: newMetadata.connectionString || newMetadata.host || newMetadata.workingDirectory
508
+ });
509
+ }
510
+ }
511
+ return true;
512
+ }
513
+ /**
514
+ * Handle disconnection from a remote session
515
+ */
516
+ handleRemoteDisconnect(connectionString, type, error) {
517
+ quickLog(`[${new Date().toISOString()}] [Warpify] Disconnected from ${type} session: ${connectionString} (${error || 'clean exit'})\n`);
518
+ // Pop the context (this was the session that just ended)
519
+ const endedContext = this.contextManager.popContext();
520
+ // Now look at the NEW current context (the parent)
521
+ const currentContext = this.contextManager.getCurrentContext();
522
+ // Restore CWD from stack - this handles any nesting level
523
+ const previousCwd = this.cwdStack.pop();
524
+ if (previousCwd) {
525
+ this.cwd = previousCwd;
526
+ }
527
+ else if (currentContext.type !== 'local') {
528
+ // Fallback: use parent context's CWD if no stack entry
529
+ this.cwd = currentContext.metadata.workingDirectory;
530
+ }
531
+ this.contextManager.updateWorkingDirectory(this.cwd);
532
+ if (currentContext.type === 'local') {
533
+ this.connectionCommandStack.pop();
534
+ }
535
+ // Notify CWD change
536
+ if (this.onCwdChange) {
537
+ this.onCwdChange(this.cwd);
538
+ }
539
+ // Save chat state
540
+ this.saveCurrentChat();
541
+ // Update UI connection status
542
+ if (this.onConnectionStatusUpdate) {
543
+ if (currentContext.type === 'local') {
544
+ this.onConnectionStatusUpdate({
545
+ type: type,
546
+ status: 'disconnected',
547
+ connectionString
548
+ });
549
+ }
550
+ else {
551
+ // Update status to reflect the parent session
552
+ let parentConnString = '';
553
+ if (currentContext.type === 'ssh') {
554
+ parentConnString = `${currentContext.metadata.username}@${currentContext.metadata.hostname}`;
555
+ }
556
+ else if (currentContext.type === 'wsl') {
557
+ parentConnString = currentContext.metadata.distroName || '';
558
+ }
559
+ else if (currentContext.type === 'docker') {
560
+ parentConnString = currentContext.metadata.containerId || '';
561
+ }
562
+ this.onConnectionStatusUpdate({
563
+ type: currentContext.type,
564
+ status: 'connected',
565
+ connectionString: parentConnString
566
+ });
567
+ }
568
+ }
569
+ // If there's an active tool execution (shell running), mark it as error
570
+ if (this.onToolExecutionUpdate && this.currentInteractiveProcess) {
571
+ this.onToolExecutionUpdate({
572
+ toolName: 'execute_command',
573
+ status: 'error',
574
+ error: `Disconnected from ${type}: ${error || 'Connection lost'}`
575
+ });
576
+ this.currentInteractiveProcess = undefined;
577
+ }
578
+ }
221
579
  /**
222
580
  * Calculate and update token count based on current conversation history
223
581
  * This ensures UI is always in sync with the actual AI context
@@ -498,6 +856,11 @@ export class CentaurusCLI {
498
856
  * Notify UI about tool execution status
499
857
  */
500
858
  notifyToolStatus(toolName, status, args, result, error) {
859
+ // Skip UI status updates for enter_remote_session - the password prompt
860
+ // and "Established Wormhole" message already provide sufficient feedback
861
+ if (toolName === 'enter_remote_session') {
862
+ return;
863
+ }
501
864
  if (this.onToolExecutionUpdate) {
502
865
  // Get current context for remote prefix
503
866
  const currentContext = this.contextManager.getCurrentContext();
@@ -574,6 +937,8 @@ export class CentaurusCLI {
574
937
  this.toolRegistry.register(createImageTool);
575
938
  this.toolRegistry.register(backgroundCommandTool);
576
939
  this.toolRegistry.register(subAgentTool);
940
+ this.toolRegistry.register(enterRemoteSessionTool);
941
+ this.toolRegistry.register(workflowTool);
577
942
  // Initialize SubAgentManager with tool registry
578
943
  SubAgentManager.initialize(this.toolRegistry);
579
944
  SubAgentManager.setOnSubAgentCountChange((count) => {
@@ -769,14 +1134,22 @@ Press Enter to continue...
769
1134
  }
770
1135
  }
771
1136
  }
772
- async handleMessage(message) {
1137
+ async handleMessage(message, options = {}) {
773
1138
  // Handle command mode - execute commands directly
774
1139
  if (this.commandMode) {
1140
+ // Record command step if workflow learning mode is active
1141
+ if (this.workflowLearningActive) {
1142
+ this.recordWorkflowStep('command', message);
1143
+ }
775
1144
  await this.handleCommandModeExecution(message);
776
1145
  return;
777
1146
  }
778
1147
  // Handle background mode - execute commands in background
779
1148
  if (this.backgroundMode) {
1149
+ // Record command step if workflow learning mode is active
1150
+ if (this.workflowLearningActive) {
1151
+ this.recordWorkflowStep('command', message);
1152
+ }
780
1153
  this.handleBackgroundModeExecution(message);
781
1154
  return;
782
1155
  }
@@ -785,6 +1158,11 @@ Press Enter to continue...
785
1158
  await this.handleSlashCommand(message);
786
1159
  return;
787
1160
  }
1161
+ // Record step if workflow learning mode is active
1162
+ // AI prompts are recorded as instruction steps
1163
+ if (this.workflowLearningActive) {
1164
+ this.recordWorkflowStep('instruction', message);
1165
+ }
788
1166
  // Check authentication
789
1167
  if (!apiClient.isAuthenticated()) {
790
1168
  throw new Error('Authentication required. Please sign in to use AI features.');
@@ -887,6 +1265,15 @@ DO NOT use write_to_file, edit_file, or execute_command until the plan is approv
887
1265
  role: 'user',
888
1266
  content: userMessageContent,
889
1267
  });
1268
+ // Calculate start index for AI context (0 for normal, current index for isolated workflow)
1269
+ const contextStartIndex = options.isolatedWorkflow ? this.conversationHistory.length - 1 : 0;
1270
+ // Helper to get messages for AI context respecting isolation
1271
+ const getMessagesForContext = () => {
1272
+ if (options.isolatedWorkflow) {
1273
+ return this.conversationHistory.slice(contextStartIndex);
1274
+ }
1275
+ return [...this.conversationHistory];
1276
+ };
890
1277
  // Messages are stored locally only - no backend persistence needed
891
1278
  // Local storage is handled by saveCurrentChat() which saves to ~/.centaurus/chats/
892
1279
  // Start logging session and log user message
@@ -931,7 +1318,7 @@ DO NOT use write_to_file, edit_file, or execute_command until the plan is approv
931
1318
  // SAFETY: Clean up any orphaned tool calls before making AI request
932
1319
  // This prevents "improperly formed request" errors from corrupted history
933
1320
  this.cleanupOrphanedToolCalls();
934
- let messages = [...this.conversationHistory];
1321
+ let messages = getMessagesForContext();
935
1322
  // Inject subshell context if in a subshell environment
936
1323
  const currentContext = this.contextManager.getCurrentContext();
937
1324
  messages = this.aiContextInjector.injectSubshellContext(messages, currentContext);
@@ -1380,7 +1767,7 @@ DO NOT use write_to_file, edit_file, or execute_command until the plan is approv
1380
1767
  // Clear pending plan request
1381
1768
  this.pendingPlanRequest = null;
1382
1769
  // Update messages array for this turn
1383
- messages = [...this.conversationHistory];
1770
+ messages = getMessagesForContext();
1384
1771
  // Continue the loop - AI will now execute with plan context
1385
1772
  continue;
1386
1773
  }
@@ -1445,7 +1832,7 @@ DO NOT use write_to_file, edit_file, or execute_command until the plan is approv
1445
1832
  });
1446
1833
  // Mark this tool call as handled so it's not duplicated
1447
1834
  handledToolCallIds.add(toolCall.id);
1448
- messages = [...this.conversationHistory];
1835
+ messages = getMessagesForContext();
1449
1836
  }
1450
1837
  }
1451
1838
  else {
@@ -1469,7 +1856,7 @@ DO NOT use write_to_file, edit_file, or execute_command until the plan is approv
1469
1856
  });
1470
1857
  // Mark this tool call as handled so it's not duplicated
1471
1858
  handledToolCallIds.add(toolCall.id);
1472
- messages = [...this.conversationHistory];
1859
+ messages = getMessagesForContext();
1473
1860
  }
1474
1861
  continue;
1475
1862
  }
@@ -1536,7 +1923,7 @@ DO NOT use write_to_file, edit_file, or execute_command until the plan is approv
1536
1923
  // Mark as handled
1537
1924
  handledToolCallIds.add(toolCall.id);
1538
1925
  // Update messages and continue
1539
- messages = [...this.conversationHistory];
1926
+ messages = getMessagesForContext();
1540
1927
  continue;
1541
1928
  }
1542
1929
  }
@@ -1878,7 +2265,7 @@ DO NOT use write_to_file, edit_file, or execute_command until the plan is approv
1878
2265
  // Rebuild messages array with updated history
1879
2266
  // During agent loop: keep ALL thinking for current task
1880
2267
  // (Thinking from previous tasks was already stripped at request start)
1881
- messages = [...this.conversationHistory];
2268
+ messages = getMessagesForContext();
1882
2269
  // No need to reset currentTurnThinking - keep accumulating for the task
1883
2270
  // Re-inject subshell context
1884
2271
  messages = this.aiContextInjector.injectSubshellContext(messages, currentContext);
@@ -1921,7 +2308,7 @@ DO NOT use write_to_file, edit_file, or execute_command until the plan is approv
1921
2308
  }
1922
2309
  // Rebuild messages array with updated history
1923
2310
  // Backend will inject system prompt
1924
- messages = [...this.conversationHistory];
2311
+ messages = getMessagesForContext();
1925
2312
  // Re-inject subshell context
1926
2313
  messages = this.aiContextInjector.injectSubshellContext(messages, currentContext);
1927
2314
  // Continue loop to get AI's response (removed 500ms delay for faster response)
@@ -1942,7 +2329,7 @@ DO NOT use write_to_file, edit_file, or execute_command until the plan is approv
1942
2329
  });
1943
2330
  // Rebuild messages array with updated history
1944
2331
  // Backend will inject system prompt
1945
- messages = [...this.conversationHistory];
2332
+ messages = getMessagesForContext();
1946
2333
  // Re-inject subshell context
1947
2334
  messages = this.aiContextInjector.injectSubshellContext(messages, currentContext);
1948
2335
  // Add delay before prompting
@@ -2276,6 +2663,11 @@ Start by listing the directory structure to understand what you're working with.
2276
2663
  break;
2277
2664
  case 'logout':
2278
2665
  try {
2666
+ // Cancel workflow learning if active before logout
2667
+ const logoutCancelMsg = this.cancelWorkflowLearning('Logging out');
2668
+ if (logoutCancelMsg && this.onDirectMessageCallback) {
2669
+ this.onDirectMessageCallback(logoutCancelMsg);
2670
+ }
2279
2671
  await apiClient.logout();
2280
2672
  responseMessage = '✅ Logged out successfully.\n\n' +
2281
2673
  'Your session has been cleared.\n' +
@@ -2354,6 +2746,11 @@ Start by listing the directory structure to understand what you're working with.
2354
2746
  '• Manual completion detection';
2355
2747
  break;
2356
2748
  case 'clear':
2749
+ // Cancel workflow learning if active before clearing
2750
+ const clearCancelMsg = this.cancelWorkflowLearning('Clearing chat session');
2751
+ if (clearCancelMsg && this.onDirectMessageCallback) {
2752
+ this.onDirectMessageCallback(clearCancelMsg);
2753
+ }
2357
2754
  // Start a new chat session (clears history and generates new chat ID)
2358
2755
  this.startNewChat();
2359
2756
  // Don't send any response message - the UI will handle clearing
@@ -2792,6 +3189,163 @@ Then try /models local again.`;
2792
3189
  ' /background-task cancel - Cancel a running background task';
2793
3190
  }
2794
3191
  break;
3192
+ case 'workflow':
3193
+ case 'wf':
3194
+ // Workflow management commands
3195
+ const wfSubCommand = args[0]?.toLowerCase();
3196
+ if (!wfSubCommand) {
3197
+ // Show workflow help
3198
+ responseMessage = `📋 **Workflow Commands:**
3199
+
3200
+ **/workflow new** - Create a new workflow
3201
+ **/workflow list** - List saved workflows
3202
+ **/workflow run <name>** - Run a workflow
3203
+ **/workflow view <name>** - View workflow steps
3204
+ **/workflow delete <name>** - Delete a workflow
3205
+
3206
+ **What are workflows?**
3207
+ Workflows let you save and replay sequences of commands and instructions.
3208
+ Create once, run many times across different machines.`;
3209
+ }
3210
+ else if (wfSubCommand === 'new' || wfSubCommand === 'create') {
3211
+ // Check for nested subcommand (manual or learn-workflow)
3212
+ const newSubCommand = args[1]?.toLowerCase();
3213
+ if (newSubCommand === 'manual') {
3214
+ // If learning mode is active, cancel it instead of starting manual mode
3215
+ if (this.workflowLearningActive) {
3216
+ responseMessage = this.cancelWorkflowLearning('Switching to manual mode');
3217
+ break; // Don't start manual mode, just show cancellation message
3218
+ }
3219
+ // Show workflow creator screen (manual mode)
3220
+ if (this.onShowWorkflowCreatorCallback) {
3221
+ this.onShowWorkflowCreatorCallback();
3222
+ return; // Don't send text response, UI will handle it
3223
+ }
3224
+ else {
3225
+ responseMessage = '❌ Workflow creator not available. Please update the CLI.';
3226
+ }
3227
+ }
3228
+ else if (newSubCommand === 'learn-workflow') {
3229
+ // Toggle workflow learning mode
3230
+ const result = this.toggleWorkflowLearning();
3231
+ if (result === null) {
3232
+ return; // UI will handle the screen change
3233
+ }
3234
+ responseMessage = result;
3235
+ }
3236
+ else if (newSubCommand) {
3237
+ // Unknown subcommand
3238
+ responseMessage = `❌ Unknown workflow new subcommand: ${newSubCommand}. Use 'manual' or 'learn-workflow'.`;
3239
+ }
3240
+ else {
3241
+ // No subcommand - show help for new options
3242
+ responseMessage = `📋 **Create a New Workflow:**
3243
+
3244
+ **/workflow new manual** - Manually create by typing steps
3245
+ **/workflow new learn-workflow** - Learn from your commands and prompts
3246
+
3247
+ **Manual Mode:** Type each step one at a time, toggle between command/instruction mode.
3248
+
3249
+ **Learn Mode:** Start learning, then run commands and prompts naturally. Run the command again to save.`;
3250
+ }
3251
+ }
3252
+ else if (wfSubCommand === 'list' || wfSubCommand === 'ls') {
3253
+ // List saved workflows
3254
+ const workflows = workflowStorage.list();
3255
+ if (workflows.length === 0) {
3256
+ responseMessage = '📭 No workflows saved yet.\n\nCreate one with: /workflow new';
3257
+ }
3258
+ else {
3259
+ responseMessage = `📋 **Saved Workflows (${workflows.length}):**\n\n`;
3260
+ for (const wf of workflows) {
3261
+ const date = new Date(wf.updatedAt).toLocaleDateString();
3262
+ responseMessage += `• **${wf.name}** (${wf.stepCount} steps)\n`;
3263
+ if (wf.description) {
3264
+ responseMessage += ` ${wf.description}\n`;
3265
+ }
3266
+ responseMessage += ` Last updated: ${date}\n\n`;
3267
+ }
3268
+ responseMessage += 'Use `/workflow run <name>` to execute a workflow.';
3269
+ }
3270
+ }
3271
+ else if (wfSubCommand === 'run' || wfSubCommand === 'execute') {
3272
+ // Run a workflow
3273
+ const wfName = args.slice(1).join(' ').trim();
3274
+ if (!wfName) {
3275
+ responseMessage = 'Usage: /workflow run <name>\n\nUse /workflow list to see available workflows.';
3276
+ }
3277
+ else {
3278
+ // Cancel workflow learning if active before running a different workflow
3279
+ const runCancelMsg = this.cancelWorkflowLearning('Running another workflow');
3280
+ if (runCancelMsg && this.onDirectMessageCallback) {
3281
+ this.onDirectMessageCallback(runCancelMsg);
3282
+ }
3283
+ const workflow = workflowStorage.load(wfName);
3284
+ if (!workflow) {
3285
+ responseMessage = `❌ Workflow '${wfName}' not found.\n\nUse /workflow list to see available workflows.`;
3286
+ }
3287
+ else {
3288
+ // Start workflow execution
3289
+ responseMessage = `🚀 Starting workflow: **${workflow.name}**\n\n` +
3290
+ `${workflow.steps.length} steps to execute...\n\n` +
3291
+ workflowStorage.formatWorkflowForDisplay(workflow);
3292
+ // Send initial response
3293
+ if (this.onDirectMessageCallback) {
3294
+ this.onDirectMessageCallback(responseMessage);
3295
+ }
3296
+ // Trigger workflow execution by sending the workflow to the AI
3297
+ // Format the workflow as a prompt for the AI to execute
3298
+ const workflowPrompt = this.buildWorkflowPrompt(workflow);
3299
+ // Use setTimeout to allow the UI to update before starting execution
3300
+ setTimeout(() => {
3301
+ this.handleMessage(workflowPrompt, { isolatedWorkflow: true });
3302
+ }, 100);
3303
+ return;
3304
+ }
3305
+ }
3306
+ }
3307
+ else if (wfSubCommand === 'view' || wfSubCommand === 'show') {
3308
+ // View a workflow's steps
3309
+ const wfName = args.slice(1).join(' ').trim();
3310
+ if (!wfName) {
3311
+ responseMessage = 'Usage: /workflow view <name>';
3312
+ }
3313
+ else {
3314
+ const workflow = workflowStorage.load(wfName);
3315
+ if (!workflow) {
3316
+ responseMessage = `❌ Workflow '${wfName}' not found.\n\nUse /workflow list to see available workflows.`;
3317
+ }
3318
+ else {
3319
+ responseMessage = workflowStorage.formatWorkflowForDisplay(workflow);
3320
+ }
3321
+ }
3322
+ }
3323
+ else if (wfSubCommand === 'delete' || wfSubCommand === 'rm' || wfSubCommand === 'remove') {
3324
+ // Delete a workflow
3325
+ const wfName = args.slice(1).join(' ').trim();
3326
+ if (!wfName) {
3327
+ responseMessage = 'Usage: /workflow delete <name>';
3328
+ }
3329
+ else {
3330
+ const result = workflowStorage.delete(wfName);
3331
+ if (result.success) {
3332
+ responseMessage = `✅ Workflow '${wfName}' deleted successfully.`;
3333
+ }
3334
+ else {
3335
+ responseMessage = `❌ ${result.error}`;
3336
+ }
3337
+ }
3338
+ }
3339
+ else {
3340
+ responseMessage = `Unknown /workflow subcommand: ${wfSubCommand}\n\n` +
3341
+ 'Usage:\n' +
3342
+ ' /workflow new - Create a new workflow\n' +
3343
+ ' /workflow list - List saved workflows\n' +
3344
+ ' /workflow run <name> - Run a workflow\n' +
3345
+ ' /workflow view <name> - View workflow steps\n' +
3346
+ ' /workflow delete <name> - Delete a workflow';
3347
+ }
3348
+ break;
2795
3349
  case 'sync':
2796
3350
  // Sync local data to/from cloud
2797
3351
  if (!apiClient.isAuthenticated()) {
@@ -3215,12 +3769,32 @@ Then try /models local again.`;
3215
3769
  }
3216
3770
  return;
3217
3771
  }
3772
+ // Check if Docker is nested inside SSH
3773
+ const parentContext = this.contextManager.getParentContext();
3218
3774
  // Start remote task first to get callbacks
3219
3775
  const remoteTask = BackgroundTaskManager.startRemoteTask(command, effectiveCwd, remoteContextDisplay || 'docker');
3220
3776
  taskId = remoteTask.id;
3221
- // Create Docker PTY with the callbacks from BackgroundTaskManager
3222
- const dockerPty = runDockerCommand(containerId, command, effectiveCwd, remoteTask.onData, remoteTask.onExit);
3223
- remoteTask.setRemotePty(dockerPty);
3777
+ if (parentContext && parentContext.type === 'ssh') {
3778
+ // Nested Docker inside SSH: route docker exec command through SSH
3779
+ const sshClient = parentContext.handler?.client;
3780
+ if (!sshClient) {
3781
+ if (this.onResponseCallback) {
3782
+ this.onResponseCallback('❌ SSH client not available for nested Docker background task');
3783
+ }
3784
+ return;
3785
+ }
3786
+ // Build docker exec command to run via SSH
3787
+ const escapedCommand = command.replace(/"/g, '\\"');
3788
+ const dockerCommand = `docker exec -w "${effectiveCwd}" ${containerId} sh -c "${escapedCommand}"`;
3789
+ // Create SSH PTY to run the docker command
3790
+ const sshPty = runSSHCommand(sshClient, dockerCommand, parentContext.metadata.workingDirectory || '~', remoteTask.onData, remoteTask.onExit);
3791
+ remoteTask.setRemotePty(sshPty);
3792
+ }
3793
+ else {
3794
+ // Local Docker: use standard runDockerCommand
3795
+ const dockerPty = runDockerCommand(containerId, command, effectiveCwd, remoteTask.onData, remoteTask.onExit);
3796
+ remoteTask.setRemotePty(dockerPty);
3797
+ }
3224
3798
  }
3225
3799
  else {
3226
3800
  // Unknown remote type - fall back to local
@@ -3306,28 +3880,57 @@ Then try /models local again.`;
3306
3880
  // Capture remote context if in remote environment (SSH/WSL/Docker)
3307
3881
  // Use null to explicitly clear remote context when not in remote (so resuming won't reconnect)
3308
3882
  let remoteContext = null;
3883
+ let remoteContextStack = null;
3309
3884
  const currentContext = this.contextManager.getCurrentContext();
3310
- if (currentContext.type !== 'local' && this.lastConnectionCommand) {
3311
- remoteContext = {
3312
- type: currentContext.type,
3313
- connectionCommand: this.lastConnectionCommand,
3314
- remoteCwd: currentContext.metadata.workingDirectory,
3315
- localCwdBeforeRemote: this.localCwdBeforeRemote || process.cwd(),
3316
- metadata: {
3317
- hostname: currentContext.metadata.hostname,
3318
- username: currentContext.metadata.username,
3319
- distroName: currentContext.metadata.distroName,
3320
- containerId: currentContext.metadata.containerId,
3321
- port: currentContext.metadata.port,
3885
+ // Build remoteContextStack for nested sessions
3886
+ if (currentContext.type !== 'local' && this.connectionCommandStack.length > 0) {
3887
+ remoteContextStack = [];
3888
+ // Build stack of contexts from the connectionCommandStack and cwdStack
3889
+ // cwdStack[0] = local CWD before first remote
3890
+ // cwdStack[1] = first remote CWD before second remote (if nested)
3891
+ // connectionCommandStack[0] = first connection command
3892
+ // connectionCommandStack[1] = second connection command (if nested)
3893
+ for (let i = 0; i < this.connectionCommandStack.length; i++) {
3894
+ const connCmd = this.connectionCommandStack[i];
3895
+ // Detect the type from the command
3896
+ let ctxType = 'ssh';
3897
+ if (connCmd.startsWith('docker ') || connCmd.startsWith('docker-compose ')) {
3898
+ ctxType = 'docker';
3322
3899
  }
3323
- };
3900
+ else if (connCmd.startsWith('wsl')) {
3901
+ ctxType = 'wsl';
3902
+ }
3903
+ // Get the CWD before this remote connection
3904
+ const cwdBefore = i < this.cwdStack.length ? this.cwdStack[i] : process.cwd();
3905
+ // For the last (current) context, use the actual metadata
3906
+ const isLastContext = i === this.connectionCommandStack.length - 1;
3907
+ const remoteCwd = isLastContext ? currentContext.metadata.workingDirectory : (this.cwdStack[i + 1] || '~');
3908
+ const storedCtx = {
3909
+ type: ctxType,
3910
+ connectionCommand: connCmd,
3911
+ remoteCwd: remoteCwd,
3912
+ localCwdBeforeRemote: cwdBefore,
3913
+ metadata: isLastContext ? {
3914
+ hostname: currentContext.metadata.hostname,
3915
+ username: currentContext.metadata.username,
3916
+ distroName: currentContext.metadata.distroName,
3917
+ containerId: currentContext.metadata.containerId,
3918
+ port: currentContext.metadata.port,
3919
+ } : {}
3920
+ };
3921
+ remoteContextStack.push(storedCtx);
3922
+ }
3923
+ // For backward compatibility, also set remoteContext to the last (current) context
3924
+ if (remoteContextStack.length > 0) {
3925
+ remoteContext = remoteContextStack[remoteContextStack.length - 1];
3926
+ }
3324
3927
  }
3325
- // Determine the local CWD to save (use localCwdBeforeRemote if in remote, otherwise current cwd)
3326
- const cwdToSave = currentContext.type !== 'local' && this.localCwdBeforeRemote
3327
- ? this.localCwdBeforeRemote
3928
+ // Determine the local CWD to save (use base of cwdStack if in remote, otherwise current cwd)
3929
+ const cwdToSave = currentContext.type !== 'local' && this.cwdStack.length > 0
3930
+ ? this.cwdStack[0]
3328
3931
  : this.cwd;
3329
3932
  try {
3330
- localChatStorage.saveChat(this.currentChatId, storedMessages, storedUIMessages, cwdToSave, remoteContext);
3933
+ localChatStorage.saveChat(this.currentChatId, storedMessages, storedUIMessages, cwdToSave, remoteContext, remoteContextStack);
3331
3934
  // Also store the backend conversation ID (UUID) for file deletion
3332
3935
  // This is the ID used for GCS file storage, not the local chat ID
3333
3936
  const backendId = conversationManager.getCurrentConversationId();
@@ -3415,8 +4018,8 @@ Then try /models local again.`;
3415
4018
  // Pop context to return to local
3416
4019
  this.contextManager.popContext();
3417
4020
  // Clear remote context tracking
3418
- this.localCwdBeforeRemote = null;
3419
- this.lastConnectionCommand = null;
4021
+ this.cwdStack = [];
4022
+ this.connectionCommandStack = [];
3420
4023
  }
3421
4024
  // Load AI context
3422
4025
  this.loadChat(chatId);
@@ -3441,28 +4044,64 @@ Then try /models local again.`;
3441
4044
  this.onRestoreMessagesCallback(restoredMessages);
3442
4045
  }
3443
4046
  // Attempt to restore remote context if chat was saved while in remote environment
3444
- if (chat.remoteContext) {
3445
- const { type, connectionCommand, remoteCwd, localCwdBeforeRemote } = chat.remoteContext;
3446
- // Store local CWD for when user exits remote
3447
- this.localCwdBeforeRemote = localCwdBeforeRemote;
3448
- this.lastConnectionCommand = connectionCommand;
3449
- // Show reconnection notification
4047
+ // Support both remoteContextStack (for nested sessions) and single remoteContext (backward compat)
4048
+ const contextStackToRestore = chat.remoteContextStack ?? (chat.remoteContext ? [chat.remoteContext] : null);
4049
+ if (contextStackToRestore && contextStackToRestore.length > 0) {
4050
+ // Get the base local CWD from the first context's localCwdBeforeRemote
4051
+ const baseLocalCwd = contextStackToRestore[0].localCwdBeforeRemote;
4052
+ // Initialize stacks
4053
+ this.cwdStack = [baseLocalCwd];
4054
+ this.connectionCommandStack = [];
4055
+ // Show initial reconnection notification
4056
+ const nestingInfo = contextStackToRestore.length > 1
4057
+ ? ` (${contextStackToRestore.length} levels: ${contextStackToRestore.map(c => c.type).join(' > ')})`
4058
+ : '';
3450
4059
  if (this.onDirectMessageCallback) {
3451
- this.onDirectMessageCallback(`🔄 Reconnecting to ${type.toUpperCase()} session...`);
3452
- }
3453
- // Show connecting status
3454
- if (this.onConnectionStatusUpdate) {
3455
- this.onConnectionStatusUpdate({
3456
- type: type,
3457
- status: 'connecting',
3458
- connectionString: this.buildConnectionString(type, chat.remoteContext.metadata)
3459
- });
4060
+ this.onDirectMessageCallback(`🔄 Reconnecting to session${nestingInfo}...`);
3460
4061
  }
3461
- try {
3462
- // Detect and connect using the saved command
3463
- const detection = this.commandDetector.detect(connectionCommand);
3464
- if (detection) {
3465
- const context = await detection.handler.connect(connectionCommand, localCwdBeforeRemote);
4062
+ // Sequential reconnection through the stack
4063
+ let previousCwd = baseLocalCwd;
4064
+ for (let i = 0; i < contextStackToRestore.length; i++) {
4065
+ const remoteCtx = contextStackToRestore[i];
4066
+ const { type, connectionCommand, remoteCwd } = remoteCtx;
4067
+ const levelInfo = contextStackToRestore.length > 1 ? ` [${i + 1}/${contextStackToRestore.length}]` : '';
4068
+ // Show connecting status for this level
4069
+ if (this.onDirectMessageCallback) {
4070
+ this.onDirectMessageCallback(`🔄${levelInfo} Connecting to ${type.toUpperCase()}...`);
4071
+ }
4072
+ if (this.onConnectionStatusUpdate) {
4073
+ this.onConnectionStatusUpdate({
4074
+ type: type,
4075
+ status: 'connecting',
4076
+ connectionString: this.buildConnectionString(type, remoteCtx.metadata)
4077
+ });
4078
+ }
4079
+ try {
4080
+ // Detect and connect using the saved command
4081
+ const detection = this.commandDetector.detect(connectionCommand);
4082
+ if (!detection) {
4083
+ throw new Error(`Could not detect handler for: ${connectionCommand}`);
4084
+ }
4085
+ let context;
4086
+ // Check if this is a nested connection (i > 0 means we're inside a remote session)
4087
+ if (i > 0) {
4088
+ const currentContext = this.contextManager.getCurrentContext();
4089
+ if (detection.handler.connectFromRemote) {
4090
+ context = await detection.handler.connectFromRemote(connectionCommand, previousCwd, currentContext);
4091
+ }
4092
+ else {
4093
+ throw new Error(`Nested connections not supported by ${type} handler`);
4094
+ }
4095
+ }
4096
+ else {
4097
+ // First level: connect from local
4098
+ context = await detection.handler.connect(connectionCommand, previousCwd);
4099
+ }
4100
+ // Push to stacks
4101
+ this.connectionCommandStack.push(connectionCommand);
4102
+ if (i > 0) {
4103
+ this.cwdStack.push(previousCwd); // Save the previous remote CWD for nested exits
4104
+ }
3466
4105
  this.contextManager.pushContext(context);
3467
4106
  // Navigate to saved remote CWD
3468
4107
  if (remoteCwd && remoteCwd !== context.metadata.workingDirectory) {
@@ -3472,38 +4111,61 @@ Then try /models local again.`;
3472
4111
  catch (cdError) {
3473
4112
  // Failed to cd to saved path - warn but continue
3474
4113
  if (this.onDirectMessageCallback) {
3475
- this.onDirectMessageCallback(`⚠️ Could not restore remote directory: ${remoteCwd}`);
4114
+ this.onDirectMessageCallback(`⚠️ Could not restore ${type} directory: ${remoteCwd}`);
3476
4115
  }
3477
4116
  }
3478
4117
  }
3479
- // Show success
4118
+ // Update previousCwd for next iteration
4119
+ previousCwd = this.contextManager.getCurrentContext().metadata.workingDirectory;
4120
+ // Show success for this level
3480
4121
  if (this.onConnectionStatusUpdate) {
3481
4122
  this.onConnectionStatusUpdate({
3482
4123
  type: type,
3483
4124
  status: 'connected',
3484
- connectionString: this.buildConnectionString(type, chat.remoteContext.metadata)
4125
+ connectionString: this.buildConnectionString(type, remoteCtx.metadata)
3485
4126
  });
3486
4127
  }
4128
+ }
4129
+ catch (error) {
4130
+ // Connection failed at this level - fall back to local mode
4131
+ // If we partially connected, disconnect all and reset
4132
+ while (this.contextManager.getCurrentContext().type !== 'local') {
4133
+ const ctx = this.contextManager.getCurrentContext();
4134
+ if (ctx.handler) {
4135
+ try {
4136
+ await ctx.handler.disconnect();
4137
+ }
4138
+ catch (e) { /* ignore */ }
4139
+ }
4140
+ this.contextManager.popContext();
4141
+ }
4142
+ this.cwdStack = [];
4143
+ this.connectionCommandStack = [];
4144
+ if (this.onConnectionStatusUpdate) {
4145
+ this.onConnectionStatusUpdate({
4146
+ type: type,
4147
+ status: 'error',
4148
+ connectionString: this.buildConnectionString(type, remoteCtx.metadata),
4149
+ error: error.message
4150
+ });
4151
+ }
4152
+ const failedAt = contextStackToRestore.length > 1 ? ` at level ${i + 1} (${type})` : '';
4153
+ if (this.onDirectMessageCallback) {
4154
+ this.onDirectMessageCallback(`⚠️ Loaded chat: "${chat.title}"\n\n❌ Could not reconnect${failedAt}: ${error.message}\n\n📁 Restored to local directory: ${baseLocalCwd}`);
4155
+ }
3487
4156
  return;
3488
4157
  }
3489
4158
  }
3490
- catch (error) {
3491
- // Connection failed - fall back to local mode
3492
- this.localCwdBeforeRemote = null;
3493
- this.lastConnectionCommand = null;
3494
- if (this.onConnectionStatusUpdate) {
3495
- this.onConnectionStatusUpdate({
3496
- type: type,
3497
- status: 'error',
3498
- connectionString: this.buildConnectionString(type, chat.remoteContext.metadata),
3499
- error: error.message
3500
- });
3501
- }
3502
- if (this.onDirectMessageCallback) {
3503
- this.onDirectMessageCallback(`⚠️ Loaded chat: "${chat.title}"\n\n❌ Could not reconnect to ${type.toUpperCase()}: ${error.message}\n\n📁 Restored to local directory: ${localCwdBeforeRemote}`);
4159
+ // All levels connected successfully
4160
+ if (this.onDirectMessageCallback) {
4161
+ const successInfo = contextStackToRestore.length > 1
4162
+ ? `🔗 Reconnected through ${contextStackToRestore.length} levels`
4163
+ : '';
4164
+ if (successInfo) {
4165
+ this.onDirectMessageCallback(successInfo);
3504
4166
  }
3505
- return;
3506
4167
  }
4168
+ return;
3507
4169
  }
3508
4170
  // No remote context - show regular confirmation
3509
4171
  // No remote context - show regular confirmation and restore CWD
@@ -3552,8 +4214,8 @@ Then try /models local again.`;
3552
4214
  this.currentChatId = null;
3553
4215
  this.conversationStarted = false;
3554
4216
  this.uiMessageHistory = [];
3555
- this.localCwdBeforeRemote = null;
3556
- this.lastConnectionCommand = null;
4217
+ this.cwdStack = [];
4218
+ this.connectionCommandStack = [];
3557
4219
  // Reset context limit state
3558
4220
  if (this.contextLimitReached) {
3559
4221
  this.contextLimitReached = false;
@@ -3689,45 +4351,6 @@ Once the user approves the plan:
3689
4351
  }
3690
4352
  }
3691
4353
  }
3692
- /**
3693
- * Handle unexpected remote session disconnect
3694
- * Called when SSH/WSL/Docker connection is lost unexpectedly
3695
- */
3696
- handleRemoteDisconnect(connectionString, type, error) {
3697
- // Pop the remote context
3698
- this.contextManager.popContext();
3699
- // Restore local CWD
3700
- if (this.localCwdBeforeRemote) {
3701
- this.cwd = this.localCwdBeforeRemote;
3702
- this.contextManager.updateWorkingDirectory(this.localCwdBeforeRemote);
3703
- if (this.onCwdChange) {
3704
- this.onCwdChange(this.localCwdBeforeRemote);
3705
- }
3706
- }
3707
- // Clear remote context tracking
3708
- this.localCwdBeforeRemote = null;
3709
- this.lastConnectionCommand = null;
3710
- // Save chat with no remote context
3711
- this.saveCurrentChat();
3712
- // Notify UI of disconnection via connection status update
3713
- if (this.onConnectionStatusUpdate) {
3714
- this.onConnectionStatusUpdate({
3715
- type: type,
3716
- status: 'disconnected',
3717
- connectionString,
3718
- error: error
3719
- });
3720
- }
3721
- // If there's an active tool execution (shell running), mark it as error
3722
- if (this.onToolExecutionUpdate && this.currentInteractiveProcess) {
3723
- this.onToolExecutionUpdate({
3724
- toolName: 'execute_command',
3725
- status: 'error',
3726
- error: `Disconnected from ${type}: ${error || 'Connection lost'}`
3727
- });
3728
- this.currentInteractiveProcess = undefined;
3729
- }
3730
- }
3731
4354
  /**
3732
4355
  * Set the current interactive process
3733
4356
  * This is called by the execute_command tool when starting an interactive command
@@ -3755,7 +4378,8 @@ Once the user approves the plan:
3755
4378
  // Remote context (SSH, WSL, Docker) - pass remote context to App
3756
4379
  // Use the remote context's working directory, not the local Windows CWD
3757
4380
  const remoteCwd = currentContextForEditor.metadata?.workingDirectory || '~';
3758
- this.onInteractiveEditorMode(true, command, remoteCwd, currentContextForEditor);
4381
+ const parentContext = this.contextManager.getParentContext() || undefined; // Convert null to undefined
4382
+ this.onInteractiveEditorMode(true, command, remoteCwd, currentContextForEditor, parentContext);
3759
4383
  }
3760
4384
  return;
3761
4385
  }
@@ -3769,20 +4393,32 @@ Once the user approves the plan:
3769
4393
  if (currentContext.handler) {
3770
4394
  await currentContext.handler.disconnect();
3771
4395
  }
3772
- // Pop context
4396
+ // Pop context - this returns us to the parent context
3773
4397
  this.contextManager.popContext();
3774
- // Restore local CWD that was saved before entering remote session
3775
- if (this.localCwdBeforeRemote) {
3776
- this.cwd = this.localCwdBeforeRemote;
3777
- this.contextManager.updateWorkingDirectory(this.localCwdBeforeRemote);
4398
+ // Check the NEW current context after popping
4399
+ const newContext = this.contextManager.getCurrentContext();
4400
+ // Pop the previous CWD from stack - this handles any nesting level
4401
+ const previousCwd = this.cwdStack.pop();
4402
+ if (previousCwd) {
4403
+ this.cwd = previousCwd;
4404
+ this.contextManager.updateWorkingDirectory(previousCwd);
4405
+ if (this.onCwdChange) {
4406
+ this.onCwdChange(previousCwd);
4407
+ }
4408
+ }
4409
+ else if (newContext.type !== 'local') {
4410
+ // Fallback: use parent context's CWD if no stack entry
4411
+ const parentCwd = newContext.metadata?.workingDirectory || '~';
4412
+ this.cwd = parentCwd;
3778
4413
  if (this.onCwdChange) {
3779
- this.onCwdChange(this.localCwdBeforeRemote);
4414
+ this.onCwdChange(parentCwd);
3780
4415
  }
3781
4416
  }
3782
- // Clear remote context tracking
3783
- this.localCwdBeforeRemote = null;
3784
- this.lastConnectionCommand = null;
3785
- // Save chat with no remote context - so resuming won't try to reconnect
4417
+ if (newContext.type === 'local') {
4418
+ // Clear tracking when back to local
4419
+ this.connectionCommandStack.pop();
4420
+ }
4421
+ // Save chat - include remote context info if still in remote
3786
4422
  this.saveCurrentChat();
3787
4423
  if (this.onResponseCallback) {
3788
4424
  this.onResponseCallback('✅ Exited subshell');
@@ -3790,87 +4426,32 @@ Once the user approves the plan:
3790
4426
  return;
3791
4427
  }
3792
4428
  }
3793
- // Detect subshell commands
4429
+ // WARPIFY MODE: SSH/WSL/Docker commands now run as NORMAL PTY commands
4430
+ // The old flow intercepted these commands and used the ssh2/etc libraries directly.
4431
+ // The new flow lets them run in focus mode, user enters password in terminal,
4432
+ // then presses Alt+E to "warpify" the session for AI context awareness.
4433
+ // The detection code is preserved below (commented) for reference.
4434
+ /*
4435
+ // Detect subshell commands (OLD FLOW - DISABLED FOR WARPIFY)
3794
4436
  const detection = this.commandDetector.detect(command);
3795
4437
  if (detection) {
3796
- // Build connection string for display (e.g., "rohan@localhost" for SSH)
3797
- let connectionString = '';
3798
- if (detection.handler.type === 'ssh') {
3799
- // Parse SSH command to get user@host
3800
- const sshMatch = command.match(/ssh\s+(?:(?:-\w+\s+)+)?(?:(\S+)@)?(\S+)/);
3801
- if (sshMatch) {
3802
- const user = sshMatch[1] || 'user';
3803
- const host = sshMatch[2] || 'remote';
3804
- connectionString = `${user}@${host}`;
3805
- }
3806
- }
3807
- else if (detection.handler.type === 'wsl') {
3808
- // Parse WSL command to get distribution name
3809
- const wslMatch = command.match(/wsl(?:\s+(?:-d|--distribution)\s+(\S+))?/);
3810
- connectionString = wslMatch?.[1] || 'Ubuntu';
3811
- }
3812
- else if (detection.handler.type === 'docker') {
3813
- // Parse Docker command to get container
3814
- const dockerMatch = command.match(/docker\s+exec\s+(?:(?:-\w+\s+)+)?(\S+)/);
3815
- connectionString = dockerMatch?.[1]?.substring(0, 12) || 'container';
3816
- }
3817
- // Show connecting message with spinner (dynamic)
3818
- if (this.onConnectionStatusUpdate) {
3819
- this.onConnectionStatusUpdate({
3820
- type: detection.handler.type,
3821
- status: 'connecting',
3822
- connectionString
3823
- });
3824
- }
3825
- // Update connection state
3826
- this.contextManager.updateConnectionState('connecting');
3827
- try {
3828
- // Save local CWD before entering remote session (for restoration when resuming chat)
3829
- if (this.contextManager.getCurrentContext().type === 'local') {
3830
- this.localCwdBeforeRemote = this.cwd;
3831
- }
3832
- this.lastConnectionCommand = command;
3833
- // Connect to subshell
3834
- const context = await detection.handler.connect(command, this.cwd);
3835
- this.contextManager.pushContext(context);
3836
- // Set up disconnect callback for SSH connections
3837
- if (detection.handler.type === 'ssh' && context.handler?.setDisconnectCallback) {
3838
- context.handler.setDisconnectCallback((error) => {
3839
- // Handle unexpected disconnect
3840
- this.handleRemoteDisconnect(connectionString, detection.handler.type, error);
3841
- });
3842
- }
3843
- // Show success message (replaces the spinner with static message)
3844
- if (this.onConnectionStatusUpdate) {
3845
- this.onConnectionStatusUpdate({
3846
- type: detection.handler.type,
3847
- status: 'connected',
3848
- connectionString
3849
- });
3850
- }
3851
- return;
3852
- }
3853
- catch (error) {
3854
- // Connection failed
3855
- this.contextManager.updateConnectionState('error');
3856
- if (this.onConnectionStatusUpdate) {
3857
- this.onConnectionStatusUpdate({
3858
- type: detection.handler.type,
3859
- status: 'error',
3860
- connectionString,
3861
- error: error.message
3862
- });
3863
- }
3864
- return;
3865
- }
4438
+ // ... old interception logic ...
4439
+ // This triggered password prompt BEFORE running the command
3866
4440
  }
4441
+ */
3867
4442
  // Special handling for cd command - change the actual working directory
3868
- const cdMatch = command.match(/^cd\s+(.+)$/);
4443
+ // Check for chained commands: cd path && cmd or cd path ; cmd
4444
+ const chainedCdMatch = command.match(/^cd\s+([^;&]+?)\s*(&&|;)\s*(.+)$/);
4445
+ const simpleCdMatch = command.match(/^cd\s+(.+)$/);
4446
+ // Determine if this starts with cd
4447
+ const cdMatch = chainedCdMatch || simpleCdMatch;
3869
4448
  if (cdMatch) {
3870
4449
  const currentContext = this.contextManager.getCurrentContext();
4450
+ const targetDir = (chainedCdMatch ? chainedCdMatch[1] : cdMatch[1]).trim();
4451
+ const hasChainedCommand = !!chainedCdMatch;
4452
+ const chainedCommand = chainedCdMatch ? chainedCdMatch[3] : null;
3871
4453
  if (currentContext.type === 'local') {
3872
4454
  // Local cd handling
3873
- const targetDir = cdMatch[1].trim();
3874
4455
  const newCwd = path.resolve(this.cwd, targetDir);
3875
4456
  if (!fs.existsSync(newCwd)) {
3876
4457
  if (this.onResponseCallback) {
@@ -3884,19 +4465,31 @@ Once the user approves the plan:
3884
4465
  }
3885
4466
  return;
3886
4467
  }
4468
+ this.cwd = newCwd;
3887
4469
  this.contextManager.updateWorkingDirectory(newCwd);
3888
4470
  if (this.onResponseCallback) {
3889
4471
  this.onResponseCallback(`Changed directory to: ${newCwd}`);
3890
4472
  }
4473
+ // If there's a chained command, execute it in the new directory
4474
+ if (hasChainedCommand && chainedCommand) {
4475
+ // Recursively handle the next command
4476
+ await this.handleCommandModeExecution(chainedCommand);
4477
+ }
3891
4478
  return;
3892
4479
  }
3893
4480
  else {
3894
- // Subshell cd handling - execute via handler
3895
- const result = await this.contextManager.executeCommand(command);
4481
+ // Subshell cd handling - execute just the cd command via handler
4482
+ const cdOnlyCommand = `cd ${targetDir}`;
4483
+ const result = await this.contextManager.executeCommand(cdOnlyCommand);
3896
4484
  if (result.exitCode === 0) {
3897
4485
  if (this.onResponseCallback) {
3898
4486
  this.onResponseCallback(`Changed directory to: ${currentContext.metadata.workingDirectory}`);
3899
4487
  }
4488
+ // If there's a chained command, execute it in the new directory
4489
+ if (hasChainedCommand && chainedCommand) {
4490
+ // Recursively handle the next command
4491
+ await this.handleCommandModeExecution(chainedCommand);
4492
+ }
3900
4493
  }
3901
4494
  else {
3902
4495
  if (this.onResponseCallback) {
@@ -4026,57 +4619,118 @@ Once the user approves the plan:
4026
4619
  });
4027
4620
  }
4028
4621
  else if (currentContext.type === 'docker') {
4029
- // Docker execution with PTY for proper TTY handling
4622
+ // Docker execution - check if nested inside SSH
4623
+ const parentContext = this.contextManager.getParentContext();
4030
4624
  const remoteCwd = currentContext.metadata?.workingDirectory || '~';
4031
4625
  const containerId = currentContext.metadata?.containerId || '';
4032
4626
  let output = '';
4033
- await new Promise((resolve) => {
4034
- const dockerPty = runDockerCommand(containerId, command, remoteCwd, (data) => {
4035
- // Stream output to UI
4036
- output += data;
4037
- if (this.onToolStreamingOutput) {
4038
- this.onToolStreamingOutput({ toolName: 'execute_command', chunk: data, type: 'stdout' });
4039
- }
4040
- }, (exitCode) => {
4041
- // Notify UI of completion
4042
- if (this.onToolExecutionUpdate) {
4043
- if (exitCode !== 0) {
4044
- this.onToolExecutionUpdate({
4045
- toolName: 'execute_command',
4046
- status: 'error',
4047
- result: output,
4048
- error: `Exit Code: ${exitCode}`,
4049
- arguments: { command, cwd: remoteCwd, remoteContext }
4050
- });
4627
+ if (parentContext && parentContext.type === 'ssh') {
4628
+ // Nested Docker inside SSH: route docker exec command through SSH
4629
+ const sshClient = parentContext.handler?.client;
4630
+ if (!sshClient) {
4631
+ throw new Error('SSH client not available for nested Docker session');
4632
+ }
4633
+ // Build docker exec command to run via SSH
4634
+ const escapedCommand = command.replace(/"/g, '\\"');
4635
+ const dockerCommand = `docker exec -w "${remoteCwd}" ${containerId} sh -c "${escapedCommand}"`;
4636
+ await new Promise((resolve) => {
4637
+ const sshPty = runSSHCommand(sshClient, dockerCommand, parentContext.metadata.workingDirectory || '~', (data) => {
4638
+ // Stream output to UI
4639
+ output += data;
4640
+ if (this.onToolStreamingOutput) {
4641
+ this.onToolStreamingOutput({ toolName: 'execute_command', chunk: data, type: 'stdout' });
4051
4642
  }
4052
- else {
4053
- this.onToolExecutionUpdate({
4054
- toolName: 'execute_command',
4055
- status: 'completed',
4056
- result: output || 'Command executed successfully',
4057
- arguments: { command, cwd: remoteCwd, remoteContext }
4058
- });
4643
+ }, (exitCode) => {
4644
+ // Notify UI of completion
4645
+ if (this.onToolExecutionUpdate) {
4646
+ if (exitCode !== 0) {
4647
+ this.onToolExecutionUpdate({
4648
+ toolName: 'execute_command',
4649
+ status: 'error',
4650
+ result: output,
4651
+ error: `Exit Code: ${exitCode}`,
4652
+ arguments: { command, cwd: remoteCwd, remoteContext }
4653
+ });
4654
+ }
4655
+ else {
4656
+ this.onToolExecutionUpdate({
4657
+ toolName: 'execute_command',
4658
+ status: 'completed',
4659
+ result: output || 'Command executed successfully',
4660
+ arguments: { command, cwd: remoteCwd, remoteContext }
4661
+ });
4662
+ }
4059
4663
  }
4060
- }
4061
- // Record shell command to AI conversation history
4062
- this.recordShellCommandToHistory(command, output, remoteCwd, exitCode);
4063
- this.currentInteractiveProcess = undefined;
4064
- resolve();
4664
+ // Record shell command to AI conversation history
4665
+ this.recordShellCommandToHistory(command, output, remoteCwd, exitCode);
4666
+ this.currentInteractiveProcess = undefined;
4667
+ resolve();
4668
+ });
4669
+ // Set up interactive process for stdin
4670
+ this.currentInteractiveProcess = {
4671
+ process: null,
4672
+ write: (data) => sshPty.write(data),
4673
+ kill: () => sshPty.kill(),
4674
+ signal: (sig) => {
4675
+ if (sig === 'SIGINT') {
4676
+ sshPty.write('\x03'); // Ctrl+C
4677
+ }
4678
+ },
4679
+ resize: (cols, rows) => sshPty.resize(cols, rows),
4680
+ isPty: true
4681
+ };
4065
4682
  });
4066
- // Set up interactive process for stdin
4067
- this.currentInteractiveProcess = {
4068
- process: null,
4069
- write: (data) => dockerPty.write(data),
4070
- kill: () => dockerPty.kill(),
4071
- signal: (sig) => {
4072
- if (sig === 'SIGINT') {
4073
- dockerPty.write('\x03'); // Ctrl+C
4683
+ }
4684
+ else {
4685
+ // Local Docker: use standard runDockerCommand
4686
+ await new Promise((resolve) => {
4687
+ const dockerPty = runDockerCommand(containerId, command, remoteCwd, (data) => {
4688
+ // Stream output to UI
4689
+ output += data;
4690
+ if (this.onToolStreamingOutput) {
4691
+ this.onToolStreamingOutput({ toolName: 'execute_command', chunk: data, type: 'stdout' });
4074
4692
  }
4075
- },
4076
- resize: (cols, rows) => dockerPty.resize(cols, rows),
4077
- isPty: true
4078
- };
4079
- });
4693
+ }, (exitCode) => {
4694
+ // Notify UI of completion
4695
+ if (this.onToolExecutionUpdate) {
4696
+ if (exitCode !== 0) {
4697
+ this.onToolExecutionUpdate({
4698
+ toolName: 'execute_command',
4699
+ status: 'error',
4700
+ result: output,
4701
+ error: `Exit Code: ${exitCode}`,
4702
+ arguments: { command, cwd: remoteCwd, remoteContext }
4703
+ });
4704
+ }
4705
+ else {
4706
+ this.onToolExecutionUpdate({
4707
+ toolName: 'execute_command',
4708
+ status: 'completed',
4709
+ result: output || 'Command executed successfully',
4710
+ arguments: { command, cwd: remoteCwd, remoteContext }
4711
+ });
4712
+ }
4713
+ }
4714
+ // Record shell command to AI conversation history
4715
+ this.recordShellCommandToHistory(command, output, remoteCwd, exitCode);
4716
+ this.currentInteractiveProcess = undefined;
4717
+ resolve();
4718
+ });
4719
+ // Set up interactive process for stdin
4720
+ this.currentInteractiveProcess = {
4721
+ process: null,
4722
+ write: (data) => dockerPty.write(data),
4723
+ kill: () => dockerPty.kill(),
4724
+ signal: (sig) => {
4725
+ if (sig === 'SIGINT') {
4726
+ dockerPty.write('\x03'); // Ctrl+C
4727
+ }
4728
+ },
4729
+ resize: (cols, rows) => dockerPty.resize(cols, rows),
4730
+ isPty: true
4731
+ };
4732
+ });
4733
+ }
4080
4734
  }
4081
4735
  else if (currentContext.type === 'ssh') {
4082
4736
  // SSH execution with PTY for proper TTY handling