centaurus-cli 2.9.9 → 3.0.1
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 +10 -0
- package/dist/cli-adapter.d.ts.map +1 -1
- package/dist/cli-adapter.js +376 -170
- package/dist/cli-adapter.js.map +1 -1
- package/dist/context/handlers/docker-handler.d.ts.map +1 -1
- package/dist/context/handlers/docker-handler.js +2 -2
- package/dist/context/handlers/docker-handler.js.map +1 -1
- package/dist/context/handlers/ssh-handler.d.ts.map +1 -1
- package/dist/context/handlers/ssh-handler.js +3 -0
- package/dist/context/handlers/ssh-handler.js.map +1 -1
- package/dist/context/handlers/wsl-handler.d.ts.map +1 -1
- package/dist/context/handlers/wsl-handler.js +9 -3
- package/dist/context/handlers/wsl-handler.js.map +1 -1
- package/dist/index.js +49 -7
- package/dist/index.js.map +1 -1
- package/dist/mcp/mcp-server-manager.d.ts.map +1 -1
- package/dist/mcp/mcp-server-manager.js +17 -3
- package/dist/mcp/mcp-server-manager.js.map +1 -1
- package/dist/services/api-client.d.ts +4 -0
- package/dist/services/api-client.d.ts.map +1 -1
- package/dist/services/api-client.js +27 -18
- package/dist/services/api-client.js.map +1 -1
- package/dist/services/checkpoint-manager.d.ts +81 -44
- package/dist/services/checkpoint-manager.d.ts.map +1 -1
- package/dist/services/checkpoint-manager.js +1219 -693
- package/dist/services/checkpoint-manager.js.map +1 -1
- package/dist/services/conversation-manager.d.ts.map +1 -1
- package/dist/services/conversation-manager.js +3 -2
- package/dist/services/conversation-manager.js.map +1 -1
- package/dist/tools/enter-remote-session.d.ts +35 -0
- package/dist/tools/enter-remote-session.d.ts.map +1 -1
- package/dist/tools/enter-remote-session.js +5 -5
- package/dist/tools/enter-remote-session.js.map +1 -1
- package/dist/tools/file-ops.d.ts.map +1 -1
- package/dist/tools/file-ops.js +39 -0
- package/dist/tools/file-ops.js.map +1 -1
- package/dist/tools/get-diff.js +8 -2
- package/dist/tools/get-diff.js.map +1 -1
- package/dist/ui/components/App.d.ts +2 -0
- package/dist/ui/components/App.d.ts.map +1 -1
- package/dist/ui/components/App.js +71 -50
- package/dist/ui/components/App.js.map +1 -1
- package/dist/ui/components/ToolExecutionMessage.js +1 -1
- package/dist/ui/components/ToolExecutionMessage.js.map +1 -1
- package/dist/utils/shell.d.ts.map +1 -1
- package/dist/utils/shell.js +12 -1
- package/dist/utils/shell.js.map +1 -1
- package/dist/utils/syntax-checker.d.ts.map +1 -1
- package/dist/utils/syntax-checker.js +26 -0
- package/dist/utils/syntax-checker.js.map +1 -1
- package/dist/utils/terminal-output.d.ts.map +1 -1
- package/dist/utils/terminal-output.js +10 -8
- package/dist/utils/terminal-output.js.map +1 -1
- package/dist/utils/text-clipboard.d.ts.map +1 -1
- package/dist/utils/text-clipboard.js +21 -8
- package/dist/utils/text-clipboard.js.map +1 -1
- package/package.json +6 -2
- package/dist/config/ConfigManager.d.ts +0 -62
- package/dist/config/ConfigManager.d.ts.map +0 -1
- package/dist/config/ConfigManager.js +0 -234
- package/dist/config/ConfigManager.js.map +0 -1
package/dist/cli-adapter.js
CHANGED
|
@@ -106,6 +106,7 @@ export class CentaurusCLI {
|
|
|
106
106
|
onBackgroundTaskViewCallback;
|
|
107
107
|
onTokenCountUpdate; // Report actual AI context token count to UI
|
|
108
108
|
currentTokenCount = 0; // Track current token count for context limit checking
|
|
109
|
+
tokenCountUpdateVersion = 0; // Guards against stale async token count updates
|
|
109
110
|
contextLimitReached = false; // Track if context limit has been reached
|
|
110
111
|
onContextLimitReached; // Notify UI about context limit state
|
|
111
112
|
onSessionQuotaUpdate;
|
|
@@ -129,6 +130,13 @@ export class CentaurusCLI {
|
|
|
129
130
|
learnedWorkflowSteps = [];
|
|
130
131
|
// Callback to set input value (e.g., for revert)
|
|
131
132
|
onSetInputCallback = null;
|
|
133
|
+
// Interrupt Queue state tracking
|
|
134
|
+
interruptQueue = [];
|
|
135
|
+
onInterruptQueueUpdateCallback;
|
|
136
|
+
onQueuedMessageDispatchedCallback;
|
|
137
|
+
static INTERRUPT_SYSTEM_NOTE_PREFIX = '[SYSTEM NOTE: The user interrupted you with the following message. Please address it, adjust your actions, and proceed with the task.]';
|
|
138
|
+
// Track if we are currently reconnecting to remote sessions (prevent race conditions)
|
|
139
|
+
isReconnecting = false;
|
|
132
140
|
constructor() {
|
|
133
141
|
this.configManager = new ConfigManager();
|
|
134
142
|
this.toolRegistry = new ToolRegistry();
|
|
@@ -243,6 +251,12 @@ export class CentaurusCLI {
|
|
|
243
251
|
setOnWorkflowSave(callback) {
|
|
244
252
|
this.onWorkflowSaveCallback = callback;
|
|
245
253
|
}
|
|
254
|
+
setOnInterruptQueueUpdate(callback) {
|
|
255
|
+
this.onInterruptQueueUpdateCallback = callback;
|
|
256
|
+
}
|
|
257
|
+
setOnQueuedMessageDispatched(callback) {
|
|
258
|
+
this.onQueuedMessageDispatchedCallback = callback;
|
|
259
|
+
}
|
|
246
260
|
setOnAiAutoSuggestChange(callback) {
|
|
247
261
|
this.onAiAutoSuggestChange = callback;
|
|
248
262
|
}
|
|
@@ -257,7 +271,7 @@ export class CentaurusCLI {
|
|
|
257
271
|
return [];
|
|
258
272
|
return this.checkpointManager.list().map(cp => ({
|
|
259
273
|
id: cp.id,
|
|
260
|
-
prompt: cp.prompt,
|
|
274
|
+
prompt: this.normalizePromptForInputBar(cp.prompt),
|
|
261
275
|
timestamp: new Date(cp.createdAt)
|
|
262
276
|
}));
|
|
263
277
|
}
|
|
@@ -400,10 +414,8 @@ Begin executing now, starting with Step 1.`;
|
|
|
400
414
|
}
|
|
401
415
|
// Check if we are already in a remote session
|
|
402
416
|
const currentContext = this.contextManager.getCurrentContext();
|
|
417
|
+
// Setup variables for the new context
|
|
403
418
|
let context;
|
|
404
|
-
// Save current CWD to stack BEFORE entering any new session (local or nested)
|
|
405
|
-
// This enables proper restoration when exiting, regardless of nesting level
|
|
406
|
-
this.cwdStack.push(this.cwd);
|
|
407
419
|
if (currentContext.type !== 'local') {
|
|
408
420
|
// Nested session: connect from remote
|
|
409
421
|
if (detection.handler.connectFromRemote) {
|
|
@@ -411,23 +423,18 @@ Begin executing now, starting with Step 1.`;
|
|
|
411
423
|
context = await detection.handler.connectFromRemote(command, this.cwd, currentContext);
|
|
412
424
|
}
|
|
413
425
|
else {
|
|
414
|
-
// If nested connection fails, pop the CWD we just pushed
|
|
415
|
-
this.cwdStack.pop();
|
|
416
426
|
throw new Error(`Nested connections are not supported by the ${type} handler yet.`);
|
|
417
427
|
}
|
|
418
428
|
}
|
|
419
429
|
else {
|
|
420
430
|
// Local session: connect from local
|
|
421
|
-
// For SSH, we shouldn't pass the local (potentially Windows) CWD as it won't exist remotely.
|
|
422
|
-
// For local->WSL or local->Docker, the handler might translate it, but strictly speaking
|
|
423
|
-
// starting 'fresh' in the remote user's home/default dir is safer for 'warpify' semantics regardless of type.
|
|
424
|
-
// However, to avoid regression for WSL/Docker usage where inheritance is expected, we restricts this fix to SSH.
|
|
425
431
|
const initialCwd = type === 'ssh' ? undefined : this.cwd;
|
|
426
|
-
// Use factory method to create a new handler instance for this session
|
|
427
|
-
// This prevents state issues where the singleton handler's client is overwritten by nested sessions
|
|
428
432
|
const newHandler = detection.handler.createNew();
|
|
429
433
|
context = await newHandler.connect(command, initialCwd);
|
|
430
434
|
}
|
|
435
|
+
// Connection succeeded! Now we can safely mutate the stacks.
|
|
436
|
+
// Save current CWD to stack BEFORE entering the new session state (local or nested)
|
|
437
|
+
this.cwdStack.push(this.cwd);
|
|
431
438
|
this.connectionCommandStack.push(command);
|
|
432
439
|
this.contextManager.pushContext(context);
|
|
433
440
|
// Explicitly sync this.cwd with the new context's CWD to ensure consistency immediately
|
|
@@ -607,6 +614,16 @@ Begin executing now, starting with Step 1.`;
|
|
|
607
614
|
this.currentInteractiveProcess = undefined;
|
|
608
615
|
}
|
|
609
616
|
}
|
|
617
|
+
normalizePromptForInputBar(prompt) {
|
|
618
|
+
if (!prompt)
|
|
619
|
+
return '';
|
|
620
|
+
if (prompt.startsWith(CentaurusCLI.INTERRUPT_SYSTEM_NOTE_PREFIX)) {
|
|
621
|
+
return prompt
|
|
622
|
+
.slice(CentaurusCLI.INTERRUPT_SYSTEM_NOTE_PREFIX.length)
|
|
623
|
+
.trimStart();
|
|
624
|
+
}
|
|
625
|
+
return prompt;
|
|
626
|
+
}
|
|
610
627
|
/**
|
|
611
628
|
* Calculate and update token count based on current conversation history
|
|
612
629
|
* This ensures UI is always in sync with the actual AI context
|
|
@@ -615,6 +632,7 @@ Begin executing now, starting with Step 1.`;
|
|
|
615
632
|
async updateTokenCount() {
|
|
616
633
|
if (!this.onTokenCountUpdate)
|
|
617
634
|
return;
|
|
635
|
+
const updateVersion = ++this.tokenCountUpdateVersion;
|
|
618
636
|
try {
|
|
619
637
|
// Get current model
|
|
620
638
|
const currentModel = this.configManager.get('modelName') || 'gemini-2.5-flash';
|
|
@@ -624,6 +642,10 @@ Begin executing now, starting with Step 1.`;
|
|
|
624
642
|
const messagesForCounting = [...this.conversationHistory];
|
|
625
643
|
// Call backend API for accurate token counting
|
|
626
644
|
const tokenCount = await apiClient.countTokens(currentModel, messagesForCounting);
|
|
645
|
+
if (updateVersion !== this.tokenCountUpdateVersion) {
|
|
646
|
+
quickLog(`[${new Date().toISOString()}] [updateTokenCount] Ignored stale token count update\n`);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
627
649
|
// Store locally for context limit checking
|
|
628
650
|
this.currentTokenCount = tokenCount;
|
|
629
651
|
// Update UI with accurate count
|
|
@@ -636,6 +658,9 @@ Begin executing now, starting with Step 1.`;
|
|
|
636
658
|
quickLog(`[${new Date().toISOString()}] [updateTokenCount] ${logMsg}`);
|
|
637
659
|
}
|
|
638
660
|
catch (error) {
|
|
661
|
+
if (updateVersion !== this.tokenCountUpdateVersion) {
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
639
664
|
// Fallback to character-based estimation if API fails
|
|
640
665
|
const SYSTEM_PROMPT_ESTIMATE = 14000; // Backend injects ~14K char system prompt
|
|
641
666
|
// Calculate total characters from conversation history
|
|
@@ -1081,6 +1106,11 @@ Press Enter to continue...
|
|
|
1081
1106
|
this.requestIntentionallyAborted = true;
|
|
1082
1107
|
this.currentAbortController.abort();
|
|
1083
1108
|
this.currentAbortController = undefined;
|
|
1109
|
+
// Clear interrupt queue on hard cancel
|
|
1110
|
+
this.interruptQueue = [];
|
|
1111
|
+
if (this.onInterruptQueueUpdateCallback) {
|
|
1112
|
+
this.onInterruptQueueUpdateCallback(this.interruptQueue);
|
|
1113
|
+
}
|
|
1084
1114
|
}
|
|
1085
1115
|
}
|
|
1086
1116
|
/**
|
|
@@ -1172,6 +1202,13 @@ Press Enter to continue...
|
|
|
1172
1202
|
}
|
|
1173
1203
|
}
|
|
1174
1204
|
async handleMessage(message, options = {}) {
|
|
1205
|
+
// Prevent messages from being processed while remote reconnection is ongoing
|
|
1206
|
+
if (this.isReconnecting) {
|
|
1207
|
+
if (this.onDirectMessageCallback) {
|
|
1208
|
+
this.onDirectMessageCallback('⏳ Please wait until the chat is fully reconnected before sending a message.');
|
|
1209
|
+
}
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1175
1212
|
// Handle command mode - execute commands directly
|
|
1176
1213
|
if (this.commandMode) {
|
|
1177
1214
|
// Record command step if workflow learning mode is active
|
|
@@ -1247,32 +1284,26 @@ Press Enter to continue...
|
|
|
1247
1284
|
// Cancel any active request when a new message comes in
|
|
1248
1285
|
// This enables "interrupt and replace" - new message takes priority
|
|
1249
1286
|
if (this.currentAbortController) {
|
|
1250
|
-
//
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
this.currentAbortController = new AbortController();
|
|
1256
|
-
oldController.abort();
|
|
1257
|
-
// Clean up orphaned tool calls from the interrupted turn
|
|
1258
|
-
this.cleanupOrphanedToolCalls();
|
|
1259
|
-
// Remove the last user message from history (it's being replaced by the new message)
|
|
1260
|
-
// Walk backwards and remove messages until we find and remove a user message
|
|
1261
|
-
while (this.conversationHistory.length > 0) {
|
|
1262
|
-
const lastMsg = this.conversationHistory[this.conversationHistory.length - 1];
|
|
1263
|
-
this.conversationHistory.pop();
|
|
1264
|
-
if (lastMsg.role === 'user') {
|
|
1265
|
-
// Found and removed the interrupted user message, stop here
|
|
1266
|
-
break;
|
|
1267
|
-
}
|
|
1268
|
-
// Continue removing assistant/tool messages that were part of the interrupted turn
|
|
1287
|
+
// INSTEAD of aborting, enqueue the message as an interrupt.
|
|
1288
|
+
const queuedMessage = options.interruptCleanText || message;
|
|
1289
|
+
this.interruptQueue.push(queuedMessage);
|
|
1290
|
+
if (this.onInterruptQueueUpdateCallback) {
|
|
1291
|
+
this.onInterruptQueueUpdateCallback(this.interruptQueue);
|
|
1269
1292
|
}
|
|
1270
|
-
quickLog(`[${new Date().toISOString()}] [handleMessage]
|
|
1293
|
+
quickLog(`[${new Date().toISOString()}] [handleMessage] Added message to interrupt queue. Queue length: ${this.interruptQueue.length}\n`);
|
|
1294
|
+
return;
|
|
1271
1295
|
}
|
|
1272
1296
|
// Store original request if in planning mode (for execution phase after approval)
|
|
1273
1297
|
if (this.planMode && !this.pendingPlanRequest) {
|
|
1274
1298
|
this.pendingPlanRequest = message;
|
|
1275
1299
|
}
|
|
1300
|
+
// Notify UI that this message is now actively being processed.
|
|
1301
|
+
// Only fire for DIRECT (non-queued) messages here.
|
|
1302
|
+
// For queued interrupts, the finally block fires this BEFORE calling handleMessage,
|
|
1303
|
+
// so we must not fire it again here — that would cause the user bubble to appear twice.
|
|
1304
|
+
if (!options.interruptCleanText && this.onQueuedMessageDispatchedCallback) {
|
|
1305
|
+
this.onQueuedMessageDispatchedCallback(message);
|
|
1306
|
+
}
|
|
1276
1307
|
// Build the user message content - inject plan mode instructions if active
|
|
1277
1308
|
let userMessageContent = message;
|
|
1278
1309
|
// When plan mode is active, explicitly inject planning instructions into the user message
|
|
@@ -1297,19 +1328,32 @@ DO NOT use write_to_file, edit_file, or execute_command until the plan is approv
|
|
|
1297
1328
|
// This clears thinking from the previous agent loop but thinking will be
|
|
1298
1329
|
// preserved for all turns within the current agent loop
|
|
1299
1330
|
this.stripThinkingFromHistory();
|
|
1300
|
-
// Add user message to history
|
|
1331
|
+
// Add user message to history, using clean text if it's an interrupt
|
|
1332
|
+
const messageToStore = options.interruptCleanText || userMessageContent;
|
|
1301
1333
|
this.conversationHistory.push({
|
|
1302
1334
|
role: 'user',
|
|
1303
|
-
content:
|
|
1335
|
+
content: messageToStore,
|
|
1304
1336
|
});
|
|
1337
|
+
// Capture the abort controller for THIS request locally so we are entirely immune to race conditions
|
|
1338
|
+
if (!this.currentAbortController) {
|
|
1339
|
+
this.currentAbortController = new AbortController();
|
|
1340
|
+
}
|
|
1341
|
+
const myAbortController = this.currentAbortController;
|
|
1305
1342
|
// Calculate start index for AI context (0 for normal, current index for isolated workflow)
|
|
1306
1343
|
const contextStartIndex = options.isolatedWorkflow ? this.conversationHistory.length - 1 : 0;
|
|
1307
1344
|
// Helper to get messages for AI context respecting isolation
|
|
1308
1345
|
const getMessagesForContext = () => {
|
|
1309
|
-
|
|
1310
|
-
|
|
1346
|
+
const msgs = options.isolatedWorkflow ? this.conversationHistory.slice(contextStartIndex) : [...this.conversationHistory];
|
|
1347
|
+
// If this is an interrupt, we must ensure the AI sees the wrapped message for this specific turn
|
|
1348
|
+
if (options.interruptCleanText && msgs.length > 0) {
|
|
1349
|
+
// Swap the last user message's content with the wrapped message just for the AI request
|
|
1350
|
+
const lastMsg = msgs[msgs.length - 1];
|
|
1351
|
+
if (lastMsg.role === 'user') {
|
|
1352
|
+
// We map over to not mutate the original history array object
|
|
1353
|
+
msgs[msgs.length - 1] = { ...lastMsg, content: userMessageContent };
|
|
1354
|
+
}
|
|
1311
1355
|
}
|
|
1312
|
-
return
|
|
1356
|
+
return msgs;
|
|
1313
1357
|
};
|
|
1314
1358
|
// Messages are stored locally only - no backend persistence needed
|
|
1315
1359
|
// Local storage is handled by saveCurrentChat() which saves to ~/.centaurus/chats/
|
|
@@ -1351,8 +1395,9 @@ DO NOT use write_to_file, edit_file, or execute_command until the plan is approv
|
|
|
1351
1395
|
}
|
|
1352
1396
|
}
|
|
1353
1397
|
try {
|
|
1398
|
+
const checkpointPrompt = options.interruptCleanText || message;
|
|
1354
1399
|
const checkpointStartPromise = this.checkpointManager.startCheckpoint({
|
|
1355
|
-
prompt:
|
|
1400
|
+
prompt: checkpointPrompt,
|
|
1356
1401
|
cwd: this.cwd,
|
|
1357
1402
|
contextType: currentContext.type,
|
|
1358
1403
|
conversationIndex: this.conversationHistory.length - 1,
|
|
@@ -1556,16 +1601,16 @@ DO NOT use write_to_file, edit_file, or execute_command until the plan is approv
|
|
|
1556
1601
|
});
|
|
1557
1602
|
// Stream AI response from backend
|
|
1558
1603
|
// Backend will inject system prompt automatically with environment context
|
|
1559
|
-
for await (const chunk of aiServiceClient.streamChat(selectedModel, messages, tools, environmentContext, mode, selectedModelThinkingConfig,
|
|
1604
|
+
for await (const chunk of aiServiceClient.streamChat(selectedModel, messages, tools, environmentContext, mode, selectedModelThinkingConfig, myAbortController.signal)) {
|
|
1560
1605
|
// Handle error chunks
|
|
1561
1606
|
if (chunk.type === 'error') {
|
|
1562
1607
|
// Check if this is an abort situation (user cancelled or sent new message)
|
|
1563
|
-
if (chunk.code === 'TIMEOUT' &&
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1608
|
+
if (chunk.code === 'TIMEOUT' && myAbortController.isIntentionalAbort) {
|
|
1609
|
+
if (!myAbortController.isReplacement) {
|
|
1610
|
+
// Clean up orphaned tool_calls from conversation history only if NOT a replacement.
|
|
1611
|
+
// If it's a replacement, the newer handleMessage already cleaned up before starting.
|
|
1612
|
+
this.cleanupOrphanedToolCalls();
|
|
1613
|
+
}
|
|
1569
1614
|
// Gracefully exit - request was intentionally cancelled
|
|
1570
1615
|
return;
|
|
1571
1616
|
}
|
|
@@ -2389,18 +2434,18 @@ DO NOT use write_to_file, edit_file, or execute_command until the plan is approv
|
|
|
2389
2434
|
}
|
|
2390
2435
|
catch (e) { }
|
|
2391
2436
|
this.conversationHistory.push(assistantHistoryMsg);
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
}
|
|
2437
|
+
// Add tool results to conversation history as tool messages
|
|
2438
|
+
// Format: { tool_call_id, name, result: <object or string> }
|
|
2439
|
+
// Only add results for unhandled tool calls (handled ones already added their own results)
|
|
2440
|
+
for (const toolResult of unhandledToolResults) {
|
|
2441
|
+
// Add tool result to conversation history as tool message
|
|
2442
|
+
// IMPORTANT: tool_call_id must be a top-level property
|
|
2443
|
+
this.conversationHistory.push({
|
|
2444
|
+
role: 'tool',
|
|
2445
|
+
tool_call_id: toolResult.tool_call_id,
|
|
2446
|
+
content: typeof toolResult.result === 'string' ? toolResult.result : JSON.stringify(toolResult.result),
|
|
2447
|
+
});
|
|
2448
|
+
}
|
|
2404
2449
|
}
|
|
2405
2450
|
// Rebuild messages array with updated history
|
|
2406
2451
|
// During agent loop: keep ALL thinking for current task
|
|
@@ -2409,6 +2454,77 @@ DO NOT use write_to_file, edit_file, or execute_command until the plan is approv
|
|
|
2409
2454
|
// No need to reset currentTurnThinking - keep accumulating for the task
|
|
2410
2455
|
// Re-inject subshell context
|
|
2411
2456
|
messages = this.aiContextInjector.injectSubshellContext(messages, currentContext);
|
|
2457
|
+
// ── Mid-turn interrupt check ──────────────────────────────────
|
|
2458
|
+
// If the user sent a message while this turn was executing, inject
|
|
2459
|
+
// it into the conversation now so the AI sees it on the very next
|
|
2460
|
+
// turn — instead of waiting for the entire multi-turn task to end.
|
|
2461
|
+
if (this.interruptQueue.length > 0) {
|
|
2462
|
+
const nextInterrupt = this.interruptQueue.shift();
|
|
2463
|
+
// Update queue UI
|
|
2464
|
+
if (this.onInterruptQueueUpdateCallback) {
|
|
2465
|
+
this.onInterruptQueueUpdateCallback(this.interruptQueue);
|
|
2466
|
+
}
|
|
2467
|
+
// Notify App.tsx to add the user bubble to messageHistory
|
|
2468
|
+
if (this.onQueuedMessageDispatchedCallback) {
|
|
2469
|
+
this.onQueuedMessageDispatchedCallback(nextInterrupt);
|
|
2470
|
+
}
|
|
2471
|
+
quickLog(`[${new Date().toISOString()}] [handleMessage] Processing mid-turn interrupt from user.\n`);
|
|
2472
|
+
// Create a checkpoint for this interrupt so it appears in /revert.
|
|
2473
|
+
// Store the clean user text (without system wrapper) for autocomplete/input prefill.
|
|
2474
|
+
if (this.checkpointManager && this.currentChatId) {
|
|
2475
|
+
try {
|
|
2476
|
+
const checkpointContext = this.contextManager.getCurrentContext();
|
|
2477
|
+
let remoteSessionInfo;
|
|
2478
|
+
let remoteHandler;
|
|
2479
|
+
if (checkpointContext.type !== 'local' && checkpointContext.handler) {
|
|
2480
|
+
const metadata = checkpointContext.metadata;
|
|
2481
|
+
remoteSessionInfo = {
|
|
2482
|
+
hostname: metadata.hostname,
|
|
2483
|
+
username: metadata.username,
|
|
2484
|
+
sessionId: checkpointContext.sessionId,
|
|
2485
|
+
connectionString: metadata.hostname
|
|
2486
|
+
? `${metadata.username || 'user'}@${metadata.hostname}`
|
|
2487
|
+
: metadata.distroName || metadata.containerId || undefined,
|
|
2488
|
+
};
|
|
2489
|
+
if (typeof checkpointContext.handler.readFile === 'function' &&
|
|
2490
|
+
typeof checkpointContext.handler.writeFile === 'function' &&
|
|
2491
|
+
typeof checkpointContext.handler.executeCommand === 'function' &&
|
|
2492
|
+
typeof checkpointContext.handler.isConnected === 'function') {
|
|
2493
|
+
remoteHandler = checkpointContext.handler;
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
// conversationIndex points to the next message slot (the interrupt user message).
|
|
2497
|
+
const interruptCheckpoint = await this.checkpointManager.startCheckpoint({
|
|
2498
|
+
prompt: nextInterrupt,
|
|
2499
|
+
cwd: this.cwd,
|
|
2500
|
+
contextType: checkpointContext.type,
|
|
2501
|
+
conversationIndex: this.conversationHistory.length,
|
|
2502
|
+
remoteSessionInfo,
|
|
2503
|
+
handler: remoteHandler,
|
|
2504
|
+
});
|
|
2505
|
+
if (interruptCheckpoint) {
|
|
2506
|
+
this.currentCheckpointId = interruptCheckpoint.id;
|
|
2507
|
+
context.currentCheckpointId = interruptCheckpoint.id;
|
|
2508
|
+
quickLog(`[${new Date().toISOString()}] [Checkpoint] Started mid-turn checkpoint ${interruptCheckpoint.id} for interrupt: "${nextInterrupt.slice(0, 50)}..."\n`);
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
catch (checkpointError) {
|
|
2512
|
+
quickLog(`[${new Date().toISOString()}] [Checkpoint] Failed to create mid-turn interrupt checkpoint: ${checkpointError?.message || checkpointError}\n`);
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
// Wrap the interrupt with context for the AI
|
|
2516
|
+
const wrappedInterrupt = `[SYSTEM NOTE: The user interrupted you with the following message. Please address it, adjust your actions, and proceed with the task.]\n\n${nextInterrupt}`;
|
|
2517
|
+
// Push the interrupt into conversation history so the AI
|
|
2518
|
+
// receives both the tool results from THIS turn and the
|
|
2519
|
+
// user's new message together on the next AI call.
|
|
2520
|
+
this.conversationHistory.push({
|
|
2521
|
+
role: 'user',
|
|
2522
|
+
content: wrappedInterrupt,
|
|
2523
|
+
});
|
|
2524
|
+
// Rebuild messages to include the interrupt
|
|
2525
|
+
messages = getMessagesForContext();
|
|
2526
|
+
messages = this.aiContextInjector.injectSubshellContext(messages, currentContext);
|
|
2527
|
+
}
|
|
2412
2528
|
continue; // Loop back to AI service
|
|
2413
2529
|
}
|
|
2414
2530
|
else {
|
|
@@ -2515,11 +2631,10 @@ DO NOT use write_to_file, edit_file, or execute_command until the plan is approv
|
|
|
2515
2631
|
// Log the error
|
|
2516
2632
|
conversationLogger.logError('handleMessage', error);
|
|
2517
2633
|
// Check if this was an abort/cancellation (including timeout errors from aborted requests)
|
|
2518
|
-
if (error.name === 'AbortError' || error.message?.includes('aborted') || error.message?.includes('timed out') ||
|
|
2634
|
+
if (error.name === 'AbortError' || error.message?.includes('aborted') || error.message?.includes('timed out') || myAbortController.isIntentionalAbort) {
|
|
2519
2635
|
// If intentionally aborted for replacement by new message, return silently
|
|
2520
2636
|
// The new message will take over - no need to show cancellation message
|
|
2521
|
-
if (
|
|
2522
|
-
this.requestIntentionallyAborted = false;
|
|
2637
|
+
if (myAbortController.isIntentionalAbort && myAbortController.isReplacement) {
|
|
2523
2638
|
return;
|
|
2524
2639
|
}
|
|
2525
2640
|
conversationLogger.logError('handleMessage', new Error('Request cancelled by user'));
|
|
@@ -2544,6 +2659,29 @@ DO NOT use write_to_file, edit_file, or execute_command until the plan is approv
|
|
|
2544
2659
|
}
|
|
2545
2660
|
// Clean up abort controller
|
|
2546
2661
|
this.currentAbortController = undefined;
|
|
2662
|
+
// Process any queued interrupts if the task wasn't aborted intentionally
|
|
2663
|
+
if (this.interruptQueue.length > 0 && !this.requestIntentionallyAborted) {
|
|
2664
|
+
// We aren't checking `myAbortController.isIntentionalAbort` here because we don't set it anymore
|
|
2665
|
+
// Next message in queue
|
|
2666
|
+
const nextInterrupt = this.interruptQueue.shift();
|
|
2667
|
+
if (this.onInterruptQueueUpdateCallback) {
|
|
2668
|
+
this.onInterruptQueueUpdateCallback(this.interruptQueue);
|
|
2669
|
+
}
|
|
2670
|
+
// Notify UI that this queued message is now being dispatched for processing.
|
|
2671
|
+
// App.tsx uses this to add the user message to messageHistory at the correct time
|
|
2672
|
+
// (i.e., when the message actually starts being processed, not when it was first queued).
|
|
2673
|
+
if (this.onQueuedMessageDispatchedCallback) {
|
|
2674
|
+
this.onQueuedMessageDispatchedCallback(nextInterrupt);
|
|
2675
|
+
}
|
|
2676
|
+
quickLog(`[${new Date().toISOString()}] [handleMessage] Processing queued interrupt from user.\n`);
|
|
2677
|
+
const wrappedMessage = `[SYSTEM NOTE: The user interrupted you with the following message. Please address it, adjust your actions, and proceed with the task.]\n\n${nextInterrupt}`;
|
|
2678
|
+
// Start next message processing asynchronously so we don't block finally block returning
|
|
2679
|
+
setTimeout(() => {
|
|
2680
|
+
this.handleMessage(wrappedMessage, { interruptCleanText: nextInterrupt }).catch(err => {
|
|
2681
|
+
conversationLogger.logError('Queued interrupt handleMessage', err);
|
|
2682
|
+
});
|
|
2683
|
+
}, 0);
|
|
2684
|
+
}
|
|
2547
2685
|
}
|
|
2548
2686
|
}
|
|
2549
2687
|
async handleSlashCommand(command) {
|
|
@@ -3306,9 +3444,10 @@ Then try /models local again.`;
|
|
|
3306
3444
|
// Truncate conversation history to the checkpoint
|
|
3307
3445
|
if (checkpointIndex >= 0) {
|
|
3308
3446
|
const checkpoint = result.checkpoint;
|
|
3447
|
+
const checkpointPromptForUi = this.normalizePromptForInputBar(checkpoint.prompt);
|
|
3309
3448
|
// Populate the input bar with the reverted prompt
|
|
3310
3449
|
if (this.onSetInputCallback) {
|
|
3311
|
-
this.onSetInputCallback(
|
|
3450
|
+
this.onSetInputCallback(checkpointPromptForUi);
|
|
3312
3451
|
}
|
|
3313
3452
|
// Use conversationIndex from metadata if available (robust)
|
|
3314
3453
|
// This avoids issues with duplicate prompts getting truncated at the wrong occurrence
|
|
@@ -3322,7 +3461,7 @@ Then try /models local again.`;
|
|
|
3322
3461
|
}
|
|
3323
3462
|
else {
|
|
3324
3463
|
// Fallback to string matching (legacy or missing index)
|
|
3325
|
-
const targetPrompt =
|
|
3464
|
+
const targetPrompt = checkpointPromptForUi;
|
|
3326
3465
|
// Find the user message with this prompt in conversationHistory
|
|
3327
3466
|
let foundIndex = -1;
|
|
3328
3467
|
// Use a broader search to find the matching message
|
|
@@ -3355,7 +3494,7 @@ Then try /models local again.`;
|
|
|
3355
3494
|
}
|
|
3356
3495
|
else {
|
|
3357
3496
|
// Fallback to string matching (legacy or missing index)
|
|
3358
|
-
const targetPrompt =
|
|
3497
|
+
const targetPrompt = checkpointPromptForUi;
|
|
3359
3498
|
const searchPrompt = targetPrompt.slice(0, 50);
|
|
3360
3499
|
let foundUiIndex = -1;
|
|
3361
3500
|
for (let i = 0; i < this.uiMessageHistory.length; i++) {
|
|
@@ -3371,15 +3510,20 @@ Then try /models local again.`;
|
|
|
3371
3510
|
quickLog(`[${new Date().toISOString()}] [Revert] Truncated UI message history to index ${foundUiIndex} (exclusive, using string match)\n`);
|
|
3372
3511
|
}
|
|
3373
3512
|
}
|
|
3513
|
+
// Recompute token count from the truncated history.
|
|
3514
|
+
// This keeps the context indicator in sync even before any follow-up prompt.
|
|
3515
|
+
await this.updateTokenCount().catch(err => {
|
|
3516
|
+
quickLog(`[${new Date().toISOString()}] [Revert] Failed to update token count after revert: ${err}\n`);
|
|
3517
|
+
});
|
|
3374
3518
|
// Remove the reverted checkpoint AND all checkpoints created after it
|
|
3375
3519
|
// They should not appear in autocomplete anymore
|
|
3376
3520
|
this.checkpointManager.removeCheckpointsFrom(checkpointArg);
|
|
3377
3521
|
// Build the success message BEFORE calling handleChatPickerSelection
|
|
3378
3522
|
// This will be passed as a parameter and included in the same React setState call
|
|
3379
3523
|
quickLog(`[${new Date().toISOString()}] [Revert] Building success message for checkpoint "${checkpointArg}"\n`);
|
|
3380
|
-
const truncatedPrompt =
|
|
3381
|
-
?
|
|
3382
|
-
:
|
|
3524
|
+
const truncatedPrompt = checkpointPromptForUi.length > 50
|
|
3525
|
+
? checkpointPromptForUi.slice(0, 50) + '...'
|
|
3526
|
+
: checkpointPromptForUi;
|
|
3383
3527
|
let revertSuccessMessage = `✅ Reverted to: "${truncatedPrompt}"\n` +
|
|
3384
3528
|
`Restored ${result.restored} files, removed ${result.removed} files.`;
|
|
3385
3529
|
if (result.errors.length > 0) {
|
|
@@ -3389,7 +3533,7 @@ Then try /models local again.`;
|
|
|
3389
3533
|
if (this.currentChatId) {
|
|
3390
3534
|
quickLog(`[${new Date().toISOString()}] [Revert] Saving truncated chat to disk (chatId: ${this.currentChatId})\n`);
|
|
3391
3535
|
quickLog(`[${new Date().toISOString()}] [Revert] uiMessageHistory.length BEFORE save: ${this.uiMessageHistory.length}\n`);
|
|
3392
|
-
this.saveCurrentChat();
|
|
3536
|
+
this.saveCurrentChat({ allowEmpty: true });
|
|
3393
3537
|
// Reload the chat to force UI update ensures consistency
|
|
3394
3538
|
// This simulates a /chat resume command which correctly populates the UI
|
|
3395
3539
|
// Pass skipLoadedMessage=true and the revert success message
|
|
@@ -3641,7 +3785,12 @@ Create once, run many times across different machines.`;
|
|
|
3641
3785
|
const workflowPrompt = this.buildWorkflowPrompt(workflow);
|
|
3642
3786
|
// Use setTimeout to allow the UI to update before starting execution
|
|
3643
3787
|
setTimeout(() => {
|
|
3644
|
-
this.handleMessage(workflowPrompt, { isolatedWorkflow: true })
|
|
3788
|
+
this.handleMessage(workflowPrompt, { isolatedWorkflow: true }).catch((err) => {
|
|
3789
|
+
quickLog(`[${new Date().toISOString()}] [Workflow] Error executing workflow: ${err.message}\n`);
|
|
3790
|
+
if (this.onDirectMessageCallback) {
|
|
3791
|
+
this.onDirectMessageCallback(`❌ Error executing workflow: ${err.message}`);
|
|
3792
|
+
}
|
|
3793
|
+
});
|
|
3645
3794
|
}, 100);
|
|
3646
3795
|
return;
|
|
3647
3796
|
}
|
|
@@ -4182,13 +4331,19 @@ Create once, run many times across different machines.`;
|
|
|
4182
4331
|
/**
|
|
4183
4332
|
* Save current conversation to local storage
|
|
4184
4333
|
*/
|
|
4185
|
-
saveCurrentChat() {
|
|
4186
|
-
|
|
4187
|
-
if (
|
|
4334
|
+
saveCurrentChat(options) {
|
|
4335
|
+
const allowEmpty = options?.allowEmpty ?? false;
|
|
4336
|
+
// Only save if there are messages (AI conversation or shell commands),
|
|
4337
|
+
// unless explicitly forced (revert-to-first-message edge case).
|
|
4338
|
+
if (this.conversationHistory.length === 0 && !allowEmpty) {
|
|
4188
4339
|
return;
|
|
4189
4340
|
}
|
|
4190
4341
|
// Generate chat ID if not exists
|
|
4191
4342
|
if (!this.currentChatId) {
|
|
4343
|
+
if (allowEmpty) {
|
|
4344
|
+
// Avoid creating brand-new empty chats.
|
|
4345
|
+
return;
|
|
4346
|
+
}
|
|
4192
4347
|
this.currentChatId = localChatStorage.generateChatId();
|
|
4193
4348
|
}
|
|
4194
4349
|
// Convert to StoredMessage format (AI context)
|
|
@@ -4403,9 +4558,10 @@ Create once, run many times across different machines.`;
|
|
|
4403
4558
|
// Restore UI messages if available
|
|
4404
4559
|
quickLog(`[${new Date().toISOString()}] [handleChatPickerSelection] chat.uiMessages exists: ${!!chat.uiMessages}, length: ${chat.uiMessages?.length ?? 0}\n`);
|
|
4405
4560
|
quickLog(`[${new Date().toISOString()}] [handleChatPickerSelection] onRestoreMessagesCallback exists: ${!!this.onRestoreMessagesCallback}\n`);
|
|
4406
|
-
if (
|
|
4561
|
+
if (this.onRestoreMessagesCallback) {
|
|
4562
|
+
const sourceUiMessages = chat.uiMessages ?? [];
|
|
4407
4563
|
// Convert StoredUIMessage back to Message format
|
|
4408
|
-
const restoredMessages =
|
|
4564
|
+
const restoredMessages = sourceUiMessages.map(msg => ({
|
|
4409
4565
|
id: msg.id,
|
|
4410
4566
|
role: msg.role,
|
|
4411
4567
|
content: msg.content,
|
|
@@ -4434,6 +4590,45 @@ Create once, run many times across different machines.`;
|
|
|
4434
4590
|
restoredMessages.push(revertSystemMessage);
|
|
4435
4591
|
quickLog(`[${new Date().toISOString()}] [handleChatPickerSelection] Appended revert success message, restoredMessages.length: ${restoredMessages.length}\n`);
|
|
4436
4592
|
}
|
|
4593
|
+
// Determine if this is a remote chat that needs reconnection
|
|
4594
|
+
const pendingContextStack = shouldPreserveRemote
|
|
4595
|
+
? null
|
|
4596
|
+
: (chat.remoteContextStack ?? (chat.remoteContext ? [chat.remoteContext] : null));
|
|
4597
|
+
const hasRemoteContext = pendingContextStack && pendingContextStack.length > 0;
|
|
4598
|
+
// IMPORTANT: Append the "Loaded chat" or "Reconnecting" status message to restoredMessages
|
|
4599
|
+
// BEFORE calling onRestoreMessagesCallback. This prevents the message from being added
|
|
4600
|
+
// separately via onDirectMessageCallback between the two-phase restore (Phase 1: clear,
|
|
4601
|
+
// Phase 2: 50ms delayed restore), which would cause Ink's Static component to increment
|
|
4602
|
+
// its rendered count and skip the first restored message.
|
|
4603
|
+
if (!skipLoadedMessage && !revertSuccessMessage) {
|
|
4604
|
+
if (hasRemoteContext) {
|
|
4605
|
+
// For remote chats, include the reconnection notification
|
|
4606
|
+
const nestingInfo = pendingContextStack.length > 1
|
|
4607
|
+
? ` (${pendingContextStack.length} levels: ${pendingContextStack.map((c) => c.type).join(' > ')})`
|
|
4608
|
+
: '';
|
|
4609
|
+
const reconnectMessage = {
|
|
4610
|
+
id: `reconnect-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
|
|
4611
|
+
role: 'assistant',
|
|
4612
|
+
content: `🔄 Reconnecting to session${nestingInfo}...`,
|
|
4613
|
+
timestamp: new Date(),
|
|
4614
|
+
shouldStream: false,
|
|
4615
|
+
};
|
|
4616
|
+
restoredMessages.push(reconnectMessage);
|
|
4617
|
+
quickLog(`[${new Date().toISOString()}] [handleChatPickerSelection] Appended reconnect message to restoredMessages\n`);
|
|
4618
|
+
}
|
|
4619
|
+
else {
|
|
4620
|
+
// For local chats, include the "Loaded chat" confirmation
|
|
4621
|
+
const loadedMessage = {
|
|
4622
|
+
id: `loaded-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
|
|
4623
|
+
role: 'assistant',
|
|
4624
|
+
content: `✅ Loaded chat: "${chat.title}"\n\nYou have ${chat.messageCount} messages in AI context. Continue your conversation!`,
|
|
4625
|
+
timestamp: new Date(),
|
|
4626
|
+
shouldStream: false,
|
|
4627
|
+
};
|
|
4628
|
+
restoredMessages.push(loadedMessage);
|
|
4629
|
+
quickLog(`[${new Date().toISOString()}] [handleChatPickerSelection] Appended loaded chat message to restoredMessages\n`);
|
|
4630
|
+
}
|
|
4631
|
+
}
|
|
4437
4632
|
quickLog(`[${new Date().toISOString()}] [handleChatPickerSelection] Calling onRestoreMessagesCallback with ${restoredMessages.length} messages\n`);
|
|
4438
4633
|
this.onRestoreMessagesCallback(restoredMessages);
|
|
4439
4634
|
quickLog(`[${new Date().toISOString()}] [handleChatPickerSelection] onRestoreMessagesCallback completed\n`);
|
|
@@ -4449,123 +4644,132 @@ Create once, run many times across different machines.`;
|
|
|
4449
4644
|
// Initialize stacks
|
|
4450
4645
|
this.cwdStack = [baseLocalCwd];
|
|
4451
4646
|
this.connectionCommandStack = [];
|
|
4452
|
-
//
|
|
4647
|
+
// Note: The initial "Reconnecting to session..." notification is now included
|
|
4648
|
+
// in the restoredMessages array (if uiMessages were restored) to avoid Ink's Static
|
|
4649
|
+
// component count desync. Only show via onDirectMessageCallback if no UI restore happened.
|
|
4453
4650
|
const nestingInfo = contextStackToRestore.length > 1
|
|
4454
4651
|
? ` (${contextStackToRestore.length} levels: ${contextStackToRestore.map(c => c.type).join(' > ')})`
|
|
4455
4652
|
: '';
|
|
4456
|
-
if (this.onDirectMessageCallback) {
|
|
4653
|
+
if (this.onDirectMessageCallback && !this.onRestoreMessagesCallback) {
|
|
4457
4654
|
this.onDirectMessageCallback(`🔄 Reconnecting to session${nestingInfo}...`);
|
|
4458
4655
|
}
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
4465
|
-
|
|
4466
|
-
|
|
4467
|
-
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
type: type,
|
|
4472
|
-
status: 'connecting',
|
|
4473
|
-
connectionString: this.buildConnectionString(type, remoteCtx.metadata)
|
|
4474
|
-
});
|
|
4475
|
-
}
|
|
4476
|
-
try {
|
|
4477
|
-
// Detect and connect using the saved command
|
|
4478
|
-
const detection = this.commandDetector.detect(connectionCommand);
|
|
4479
|
-
if (!detection) {
|
|
4480
|
-
throw new Error(`Could not detect handler for: ${connectionCommand}`);
|
|
4481
|
-
}
|
|
4482
|
-
// Create a new instance of the handler to ensure each level of the connection
|
|
4483
|
-
// has its own state (client, stream, etc.), critical for nested sessions of the same type
|
|
4484
|
-
const handler = detection.handler.createNew();
|
|
4485
|
-
let context;
|
|
4486
|
-
// Check if this is a nested connection (i > 0 means we're inside a remote session)
|
|
4487
|
-
if (i > 0) {
|
|
4488
|
-
const currentContext = this.contextManager.getCurrentContext();
|
|
4489
|
-
if (handler.connectFromRemote) {
|
|
4490
|
-
context = await handler.connectFromRemote(connectionCommand, previousCwd, currentContext);
|
|
4491
|
-
}
|
|
4492
|
-
else {
|
|
4493
|
-
throw new Error(`Nested connections not supported by ${type} handler`);
|
|
4494
|
-
}
|
|
4495
|
-
}
|
|
4496
|
-
else {
|
|
4497
|
-
// First level: connect from local
|
|
4498
|
-
context = await handler.connect(connectionCommand, previousCwd);
|
|
4499
|
-
}
|
|
4500
|
-
// Push to stacks
|
|
4501
|
-
this.connectionCommandStack.push(connectionCommand);
|
|
4502
|
-
if (i > 0) {
|
|
4503
|
-
this.cwdStack.push(previousCwd); // Save the previous remote CWD for nested exits
|
|
4504
|
-
}
|
|
4505
|
-
this.contextManager.pushContext(context);
|
|
4506
|
-
// Navigate to saved remote CWD
|
|
4507
|
-
if (remoteCwd && remoteCwd !== context.metadata.workingDirectory) {
|
|
4508
|
-
try {
|
|
4509
|
-
await this.contextManager.executeCommand(`cd "${remoteCwd}"`);
|
|
4510
|
-
}
|
|
4511
|
-
catch (cdError) {
|
|
4512
|
-
// Failed to cd to saved path - warn but continue
|
|
4513
|
-
if (this.onDirectMessageCallback) {
|
|
4514
|
-
this.onDirectMessageCallback(`⚠️ Could not restore ${type} directory: ${remoteCwd}`);
|
|
4515
|
-
}
|
|
4516
|
-
}
|
|
4656
|
+
this.isReconnecting = true;
|
|
4657
|
+
try {
|
|
4658
|
+
// Sequential reconnection through the stack
|
|
4659
|
+
let previousCwd = baseLocalCwd;
|
|
4660
|
+
for (let i = 0; i < contextStackToRestore.length; i++) {
|
|
4661
|
+
const remoteCtx = contextStackToRestore[i];
|
|
4662
|
+
const { type, connectionCommand, remoteCwd } = remoteCtx;
|
|
4663
|
+
const levelInfo = contextStackToRestore.length > 1 ? ` [${i + 1}/${contextStackToRestore.length}]` : '';
|
|
4664
|
+
// Show connecting status for this level
|
|
4665
|
+
// Only show via onDirectMessageCallback if no UI restore happened (same reason as reconnecting message above)
|
|
4666
|
+
if (this.onDirectMessageCallback && !this.onRestoreMessagesCallback) {
|
|
4667
|
+
this.onDirectMessageCallback(`🔄${levelInfo} Connecting to ${type.toUpperCase()}...`);
|
|
4517
4668
|
}
|
|
4518
|
-
// Update previousCwd for next iteration
|
|
4519
|
-
previousCwd = this.contextManager.getCurrentContext().metadata.workingDirectory;
|
|
4520
|
-
// Show success for this level
|
|
4521
4669
|
if (this.onConnectionStatusUpdate) {
|
|
4522
4670
|
this.onConnectionStatusUpdate({
|
|
4523
4671
|
type: type,
|
|
4524
|
-
status: '
|
|
4672
|
+
status: 'connecting',
|
|
4525
4673
|
connectionString: this.buildConnectionString(type, remoteCtx.metadata)
|
|
4526
4674
|
});
|
|
4527
4675
|
}
|
|
4528
|
-
|
|
4529
|
-
|
|
4530
|
-
|
|
4531
|
-
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
|
|
4676
|
+
try {
|
|
4677
|
+
// Detect and connect using the saved command
|
|
4678
|
+
const detection = this.commandDetector.detect(connectionCommand);
|
|
4679
|
+
if (!detection) {
|
|
4680
|
+
throw new Error(`Could not detect handler for: ${connectionCommand}`);
|
|
4681
|
+
}
|
|
4682
|
+
// Create a new instance of the handler to ensure each level of the connection
|
|
4683
|
+
// has its own state (client, stream, etc.), critical for nested sessions of the same type
|
|
4684
|
+
const handler = detection.handler.createNew();
|
|
4685
|
+
let context;
|
|
4686
|
+
// Check if this is a nested connection (i > 0 means we're inside a remote session)
|
|
4687
|
+
if (i > 0) {
|
|
4688
|
+
const currentContext = this.contextManager.getCurrentContext();
|
|
4689
|
+
if (handler.connectFromRemote) {
|
|
4690
|
+
context = await handler.connectFromRemote(connectionCommand, previousCwd, currentContext);
|
|
4691
|
+
}
|
|
4692
|
+
else {
|
|
4693
|
+
throw new Error(`Nested connections not supported by ${type} handler`);
|
|
4694
|
+
}
|
|
4695
|
+
}
|
|
4696
|
+
else {
|
|
4697
|
+
// First level: connect from local
|
|
4698
|
+
context = await handler.connect(connectionCommand, previousCwd);
|
|
4699
|
+
}
|
|
4700
|
+
// Push to stacks
|
|
4701
|
+
this.connectionCommandStack.push(connectionCommand);
|
|
4702
|
+
if (i > 0) {
|
|
4703
|
+
this.cwdStack.push(previousCwd); // Save the previous remote CWD for nested exits
|
|
4704
|
+
}
|
|
4705
|
+
this.contextManager.pushContext(context);
|
|
4706
|
+
// Navigate to saved remote CWD
|
|
4707
|
+
if (remoteCwd && remoteCwd !== context.metadata.workingDirectory) {
|
|
4535
4708
|
try {
|
|
4536
|
-
await
|
|
4709
|
+
await this.contextManager.executeCommand(`cd "${remoteCwd}"`);
|
|
4710
|
+
}
|
|
4711
|
+
catch (cdError) {
|
|
4712
|
+
// Failed to cd to saved path - warn but continue
|
|
4713
|
+
if (this.onDirectMessageCallback) {
|
|
4714
|
+
this.onDirectMessageCallback(`⚠️ Could not restore ${type} directory: ${remoteCwd}`);
|
|
4715
|
+
}
|
|
4537
4716
|
}
|
|
4538
|
-
catch (e) { /* ignore */ }
|
|
4539
4717
|
}
|
|
4540
|
-
|
|
4718
|
+
// Update previousCwd for next iteration
|
|
4719
|
+
previousCwd = this.contextManager.getCurrentContext().metadata.workingDirectory;
|
|
4720
|
+
// Show success for this level
|
|
4721
|
+
if (this.onConnectionStatusUpdate) {
|
|
4722
|
+
this.onConnectionStatusUpdate({
|
|
4723
|
+
type: type,
|
|
4724
|
+
status: 'connected',
|
|
4725
|
+
connectionString: this.buildConnectionString(type, remoteCtx.metadata)
|
|
4726
|
+
});
|
|
4727
|
+
}
|
|
4541
4728
|
}
|
|
4542
|
-
|
|
4543
|
-
|
|
4544
|
-
|
|
4545
|
-
this.
|
|
4546
|
-
|
|
4547
|
-
|
|
4548
|
-
|
|
4549
|
-
|
|
4550
|
-
|
|
4729
|
+
catch (error) {
|
|
4730
|
+
// Connection failed at this level - fall back to local mode
|
|
4731
|
+
// If we partially connected, disconnect all and reset
|
|
4732
|
+
while (this.contextManager.getCurrentContext().type !== 'local') {
|
|
4733
|
+
const ctx = this.contextManager.getCurrentContext();
|
|
4734
|
+
if (ctx.handler) {
|
|
4735
|
+
try {
|
|
4736
|
+
await ctx.handler.disconnect();
|
|
4737
|
+
}
|
|
4738
|
+
catch (e) { /* ignore */ }
|
|
4739
|
+
}
|
|
4740
|
+
this.contextManager.popContext();
|
|
4741
|
+
}
|
|
4742
|
+
this.cwdStack = [];
|
|
4743
|
+
this.connectionCommandStack = [];
|
|
4744
|
+
if (this.onConnectionStatusUpdate) {
|
|
4745
|
+
this.onConnectionStatusUpdate({
|
|
4746
|
+
type: type,
|
|
4747
|
+
status: 'error',
|
|
4748
|
+
connectionString: this.buildConnectionString(type, remoteCtx.metadata),
|
|
4749
|
+
error: error.message
|
|
4750
|
+
});
|
|
4751
|
+
}
|
|
4752
|
+
const failedAt = contextStackToRestore.length > 1 ? ` at level ${i + 1} (${type})` : '';
|
|
4753
|
+
if (this.onDirectMessageCallback) {
|
|
4754
|
+
this.onDirectMessageCallback(`⚠️ Loaded chat: "${chat.title}"\n\n❌ Could not reconnect${failedAt}: ${error.message}\n\n📁 Restored to local directory: ${baseLocalCwd}`);
|
|
4755
|
+
}
|
|
4756
|
+
return;
|
|
4551
4757
|
}
|
|
4552
|
-
|
|
4553
|
-
|
|
4554
|
-
|
|
4758
|
+
}
|
|
4759
|
+
// All levels connected successfully
|
|
4760
|
+
if (this.onDirectMessageCallback) {
|
|
4761
|
+
const successInfo = contextStackToRestore.length > 1
|
|
4762
|
+
? `🔗 Reconnected through ${contextStackToRestore.length} levels`
|
|
4763
|
+
: '';
|
|
4764
|
+
if (successInfo) {
|
|
4765
|
+
this.onDirectMessageCallback(successInfo);
|
|
4555
4766
|
}
|
|
4556
|
-
return;
|
|
4557
4767
|
}
|
|
4768
|
+
return;
|
|
4558
4769
|
}
|
|
4559
|
-
|
|
4560
|
-
|
|
4561
|
-
const successInfo = contextStackToRestore.length > 1
|
|
4562
|
-
? `🔗 Reconnected through ${contextStackToRestore.length} levels`
|
|
4563
|
-
: '';
|
|
4564
|
-
if (successInfo) {
|
|
4565
|
-
this.onDirectMessageCallback(successInfo);
|
|
4566
|
-
}
|
|
4770
|
+
finally {
|
|
4771
|
+
this.isReconnecting = false;
|
|
4567
4772
|
}
|
|
4568
|
-
return;
|
|
4569
4773
|
}
|
|
4570
4774
|
// No remote context - show regular confirmation and restore CWD
|
|
4571
4775
|
if (!shouldPreserveRemote && chat.cwd && !chat.remoteContext) {
|
|
@@ -4578,7 +4782,9 @@ Create once, run many times across different machines.`;
|
|
|
4578
4782
|
}
|
|
4579
4783
|
}
|
|
4580
4784
|
// Skip loaded message if called from revert (to avoid React setState race condition)
|
|
4581
|
-
if (
|
|
4785
|
+
// Also skip if we already included it in the restoredMessages array (when uiMessages were restored)
|
|
4786
|
+
const uiMessagesWereRestored = !!this.onRestoreMessagesCallback;
|
|
4787
|
+
if (this.onDirectMessageCallback && !skipLoadedMessage && !uiMessagesWereRestored) {
|
|
4582
4788
|
const responseMessage = `✅ Loaded chat: "${chat.title}"\n\nYou have ${chat.messageCount} messages in AI context. Continue your conversation!`;
|
|
4583
4789
|
this.onDirectMessageCallback(responseMessage);
|
|
4584
4790
|
}
|