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.
- package/dist/cli-adapter.d.ts +74 -10
- package/dist/cli-adapter.d.ts.map +1 -1
- package/dist/cli-adapter.js +898 -244
- package/dist/cli-adapter.js.map +1 -1
- package/dist/commands/CommandParser.d.ts +1 -1
- package/dist/commands/CommandParser.d.ts.map +1 -1
- package/dist/commands/CommandParser.js +113 -0
- package/dist/commands/CommandParser.js.map +1 -1
- package/dist/config/slash-commands.d.ts +2 -0
- package/dist/config/slash-commands.d.ts.map +1 -1
- package/dist/config/slash-commands.js +28 -0
- package/dist/config/slash-commands.js.map +1 -1
- package/dist/context/context-manager.d.ts +7 -1
- package/dist/context/context-manager.d.ts.map +1 -1
- package/dist/context/context-manager.js +14 -1
- package/dist/context/context-manager.js.map +1 -1
- package/dist/context/handlers/docker-handler.d.ts +11 -0
- package/dist/context/handlers/docker-handler.d.ts.map +1 -1
- package/dist/context/handlers/docker-handler.js +159 -14
- package/dist/context/handlers/docker-handler.js.map +1 -1
- package/dist/context/handlers/ssh-handler.d.ts +20 -0
- package/dist/context/handlers/ssh-handler.d.ts.map +1 -1
- package/dist/context/handlers/ssh-handler.js +129 -1
- package/dist/context/handlers/ssh-handler.js.map +1 -1
- package/dist/context/subshell-handler.d.ts +15 -0
- package/dist/context/subshell-handler.d.ts.map +1 -1
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/services/ai-service-client.d.ts.map +1 -1
- package/dist/services/ai-service-client.js +33 -11
- package/dist/services/ai-service-client.js.map +1 -1
- package/dist/services/api-client.js +1 -1
- package/dist/services/api-client.js.map +1 -1
- package/dist/services/local-chat-storage.d.ts +3 -1
- package/dist/services/local-chat-storage.d.ts.map +1 -1
- package/dist/services/local-chat-storage.js +8 -3
- package/dist/services/local-chat-storage.js.map +1 -1
- package/dist/services/warpify-detector.d.ts +43 -0
- package/dist/services/warpify-detector.d.ts.map +1 -0
- package/dist/services/warpify-detector.js +203 -0
- package/dist/services/warpify-detector.js.map +1 -0
- package/dist/services/workflow-storage.d.ts +72 -0
- package/dist/services/workflow-storage.d.ts.map +1 -0
- package/dist/services/workflow-storage.js +239 -0
- package/dist/services/workflow-storage.js.map +1 -0
- package/dist/tools/command.d.ts.map +1 -1
- package/dist/tools/command.js +106 -38
- package/dist/tools/command.js.map +1 -1
- package/dist/tools/enter-remote-session.d.ts +13 -0
- package/dist/tools/enter-remote-session.d.ts.map +1 -0
- package/dist/tools/enter-remote-session.js +226 -0
- package/dist/tools/enter-remote-session.js.map +1 -0
- package/dist/tools/find-files.d.ts.map +1 -1
- package/dist/tools/find-files.js +9 -2
- package/dist/tools/find-files.js.map +1 -1
- package/dist/tools/grep-search.d.ts +104 -31
- package/dist/tools/grep-search.d.ts.map +1 -1
- package/dist/tools/grep-search.js +779 -431
- package/dist/tools/grep-search.js.map +1 -1
- package/dist/tools/workflow-tool.d.ts +11 -0
- package/dist/tools/workflow-tool.d.ts.map +1 -0
- package/dist/tools/workflow-tool.js +87 -0
- package/dist/tools/workflow-tool.js.map +1 -0
- package/dist/types/workflow.d.ts +110 -0
- package/dist/types/workflow.d.ts.map +1 -0
- package/dist/types/workflow.js +8 -0
- package/dist/types/workflow.js.map +1 -0
- package/dist/ui/components/App.d.ts +10 -1
- package/dist/ui/components/App.d.ts.map +1 -1
- package/dist/ui/components/App.js +135 -8
- package/dist/ui/components/App.js.map +1 -1
- package/dist/ui/components/Breadcrumbs.d.ts +4 -3
- package/dist/ui/components/Breadcrumbs.d.ts.map +1 -1
- package/dist/ui/components/Breadcrumbs.js +80 -54
- package/dist/ui/components/Breadcrumbs.js.map +1 -1
- package/dist/ui/components/ConnectionStatusMessage.js +2 -2
- package/dist/ui/components/ConnectionStatusMessage.js.map +1 -1
- package/dist/ui/components/InputBox.d.ts +1 -0
- package/dist/ui/components/InputBox.d.ts.map +1 -1
- package/dist/ui/components/InputBox.js +226 -19
- package/dist/ui/components/InputBox.js.map +1 -1
- package/dist/ui/components/InteractiveShell.d.ts +4 -0
- package/dist/ui/components/InteractiveShell.d.ts.map +1 -1
- package/dist/ui/components/InteractiveShell.js +52 -15
- package/dist/ui/components/InteractiveShell.js.map +1 -1
- package/dist/ui/components/KeyboardHelp.d.ts.map +1 -1
- package/dist/ui/components/KeyboardHelp.js +14 -6
- package/dist/ui/components/KeyboardHelp.js.map +1 -1
- package/dist/ui/components/ToolExecutionMessage.d.ts.map +1 -1
- package/dist/ui/components/ToolExecutionMessage.js +165 -27
- package/dist/ui/components/ToolExecutionMessage.js.map +1 -1
- package/dist/ui/components/WorkflowCreatorScreen.d.ts +25 -0
- package/dist/ui/components/WorkflowCreatorScreen.d.ts.map +1 -0
- package/dist/ui/components/WorkflowCreatorScreen.js +164 -0
- package/dist/ui/components/WorkflowCreatorScreen.js.map +1 -0
- package/dist/utils/ansi-encoder.d.ts.map +1 -1
- package/dist/utils/ansi-encoder.js +7 -0
- package/dist/utils/ansi-encoder.js.map +1 -1
- package/dist/utils/editor-utils.d.ts +9 -0
- package/dist/utils/editor-utils.d.ts.map +1 -1
- package/dist/utils/editor-utils.js +105 -0
- package/dist/utils/editor-utils.js.map +1 -1
- package/dist/utils/input-classifier.d.ts.map +1 -1
- package/dist/utils/input-classifier.js +2 -1
- package/dist/utils/input-classifier.js.map +1 -1
- package/dist/utils/terminal-output.d.ts +3 -1
- package/dist/utils/terminal-output.d.ts.map +1 -1
- package/dist/utils/terminal-output.js +138 -157
- package/dist/utils/terminal-output.js.map +1 -1
- package/package.json +1 -1
package/dist/cli-adapter.js
CHANGED
|
@@ -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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
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
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
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
|
|
3326
|
-
const cwdToSave = currentContext.type !== 'local' && this.
|
|
3327
|
-
? this.
|
|
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.
|
|
3419
|
-
this.
|
|
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
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
//
|
|
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 ${
|
|
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
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
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
|
|
4114
|
+
this.onDirectMessageCallback(`⚠️ Could not restore ${type} directory: ${remoteCwd}`);
|
|
3476
4115
|
}
|
|
3477
4116
|
}
|
|
3478
4117
|
}
|
|
3479
|
-
//
|
|
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,
|
|
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
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
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.
|
|
3556
|
-
this.
|
|
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.
|
|
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
|
-
//
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
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(
|
|
4414
|
+
this.onCwdChange(parentCwd);
|
|
3780
4415
|
}
|
|
3781
4416
|
}
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
3797
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
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
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
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
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
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
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
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
|
-
|
|
4077
|
-
|
|
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
|