agentgui 1.0.672 → 1.0.674

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.
@@ -546,3 +546,74 @@ export async function autoProvision(broadcast) {
546
546
  }
547
547
  log('Provisioning complete');
548
548
  }
549
+
550
+ // Periodic tool update checker - runs in background every 6 hours
551
+ let updateCheckInterval = null;
552
+ const UPDATE_CHECK_INTERVAL = 6 * 60 * 60 * 1000; // 6 hours
553
+
554
+ export function startPeriodicUpdateCheck(broadcast) {
555
+ const log = (msg) => console.log('[TOOLS-PERIODIC] ' + msg);
556
+
557
+ if (updateCheckInterval) {
558
+ log('Update check already running');
559
+ return;
560
+ }
561
+
562
+ log('Starting periodic tool update checker (every 6 hours)');
563
+
564
+ // Run check immediately on startup (non-blocking)
565
+ setImmediate(() => {
566
+ checkAndUpdateTools(broadcast).catch(err => {
567
+ log(`Initial check failed: ${err.message}`);
568
+ });
569
+ });
570
+
571
+ // Then run periodically every 6 hours
572
+ updateCheckInterval = setInterval(() => {
573
+ checkAndUpdateTools(broadcast).catch(err => {
574
+ log(`Periodic check failed: ${err.message}`);
575
+ });
576
+ }, UPDATE_CHECK_INTERVAL);
577
+ }
578
+
579
+ export function stopPeriodicUpdateCheck() {
580
+ if (updateCheckInterval) {
581
+ clearInterval(updateCheckInterval);
582
+ updateCheckInterval = null;
583
+ console.log('[TOOLS-PERIODIC] Update check stopped');
584
+ }
585
+ }
586
+
587
+ async function checkAndUpdateTools(broadcast) {
588
+ const log = (msg) => console.log('[TOOLS-PERIODIC] ' + msg);
589
+ log('Checking for tool updates...');
590
+
591
+ for (const tool of TOOLS) {
592
+ try {
593
+ const status = await checkToolViaBunx(tool.pkg, tool.pluginId, tool.category, tool.frameWork, false);
594
+
595
+ if (status.upgradeNeeded) {
596
+ log(`Update available for ${tool.id}: ${status.installedVersion} -> ${status.publishedVersion}`);
597
+ broadcast({ type: 'tool_update_available', toolId: tool.id, data: { installedVersion: status.installedVersion, publishedVersion: status.publishedVersion } });
598
+
599
+ // Auto-update in background (non-blocking)
600
+ log(`Auto-updating ${tool.id}...`);
601
+ const result = await update(tool.id, (msg) => {
602
+ broadcast({ type: 'tool_update_progress', toolId: tool.id, data: msg });
603
+ });
604
+
605
+ if (result.success) {
606
+ log(`${tool.id} auto-updated to v${result.version}`);
607
+ broadcast({ type: 'tool_update_complete', toolId: tool.id, data: { ...result, autoUpdated: true } });
608
+ } else {
609
+ log(`${tool.id} auto-update failed: ${result.error}`);
610
+ broadcast({ type: 'tool_update_failed', toolId: tool.id, data: { ...result, autoUpdated: true } });
611
+ }
612
+ }
613
+ } catch (err) {
614
+ log(`Error checking ${tool.id}: ${err.message}`);
615
+ }
616
+ }
617
+
618
+ log('Update check complete');
619
+ }
@@ -7,7 +7,7 @@ function expandTilde(p) { return p && p.startsWith('~') ? path.join(os.homedir()
7
7
 
8
8
  export function register(router, deps) {
9
9
  const { queries, activeExecutions, messageQueues, rateLimitState,
10
- broadcastSync, processMessageWithStreaming, activeProcessesByConvId, steeringTimeouts } = deps;
10
+ broadcastSync, processMessageWithStreaming, activeProcessesByConvId, steeringTimeouts, cleanupExecution } = deps;
11
11
 
12
12
  router.handle('conv.ls', () => {
13
13
  const conversations = queries.getConversationsList();
@@ -95,8 +95,10 @@ export function register(router, deps) {
95
95
  const { pid, sessionId } = entry;
96
96
  if (pid) { try { process.kill(-pid, 'SIGKILL'); } catch { try { process.kill(pid, 'SIGKILL'); } catch {} } }
97
97
  if (sessionId) queries.updateSession(sessionId, { status: 'interrupted', completed_at: Date.now() });
98
- queries.setIsStreaming(p.id, false);
99
- activeExecutions.delete(p.id);
98
+
99
+ // Use atomic cleanup function to ensure state consistency
100
+ cleanupExecution(p.id, false);
101
+
100
102
  broadcastSync({ type: 'streaming_complete', sessionId, conversationId: p.id, interrupted: true, timestamp: Date.now() });
101
103
  return { ok: true, cancelled: true, conversationId: p.id, sessionId };
102
104
  });
@@ -152,6 +152,32 @@ function register(router, deps) {
152
152
  if (!run || run.thread_id !== threadId) err(404, 'Run not found on thread');
153
153
  return run;
154
154
  });
155
+
156
+ router.handle('thread.run.steer', async (p) => {
157
+ const threadId = need(p, 'id'), runId = need(p, 'runId'), instruction = need(p, 'instruction');
158
+ const thread = getThreadOrThrow(threadId);
159
+ const run = getRunOrThrow(runId);
160
+ if (run.thread_id !== threadId) err(400, 'Run does not belong to specified thread');
161
+ if (!['active', 'pending'].includes(run.status)) err(409, 'Run is not active or pending');
162
+
163
+ // Cancel current run
164
+ const cancelled = queries.cancelRun(runId);
165
+ const ex = killExecution(threadId, runId);
166
+ broadcastSync({ type: 'run_cancelled', runId, threadId, sessionId: ex?.sessionId, timestamp: Date.now() });
167
+
168
+ // Create new run with steering instruction injected
169
+ // The instruction will be processed as a follow-up in the same thread context
170
+ const newRun = queries.createRun(run.agent_id, threadId, { content: instruction }, run.config);
171
+ startExecution(newRun.run_id, threadId, run.agent_id, { content: instruction }, run.config);
172
+
173
+ return {
174
+ steered: true,
175
+ cancelled_run: cancelled,
176
+ new_run: newRun,
177
+ instruction: instruction,
178
+ message: 'Run interrupted and restarted with steering instruction'
179
+ };
180
+ });
155
181
  }
156
182
 
157
183
  export { register };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.672",
3
+ "version": "1.0.674",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -311,6 +311,45 @@ const debugLog = (msg) => {
311
311
  console.error(`[${timestamp}] ${msg}`);
312
312
  };
313
313
 
314
+ // Atomic cleanup function - ensures consistent state across all data structures
315
+ function cleanupExecution(conversationId, broadcastCompletion = false) {
316
+ debugLog(`[cleanup] Starting cleanup for ${conversationId}`);
317
+
318
+ // Clean in-memory maps in atomic block
319
+ activeExecutions.delete(conversationId);
320
+ const proc = activeProcessesByConvId.get(conversationId);
321
+ activeProcessesByConvId.delete(conversationId);
322
+
323
+ // Cancel steering timeout if present
324
+ const steeringTimeout = steeringTimeouts.get(conversationId);
325
+ if (steeringTimeout) {
326
+ clearTimeout(steeringTimeout);
327
+ steeringTimeouts.delete(conversationId);
328
+ }
329
+
330
+ // Try to kill process if still alive
331
+ if (proc) {
332
+ try {
333
+ if (proc.kill) proc.kill('SIGTERM');
334
+ } catch (e) {
335
+ debugLog(`[cleanup] Error killing process: ${e.message}`);
336
+ }
337
+ }
338
+
339
+ // Clean database state
340
+ queries.setIsStreaming(conversationId, false);
341
+
342
+ if (broadcastCompletion) {
343
+ broadcastSync({
344
+ type: 'execution_cleaned_up',
345
+ conversationId,
346
+ timestamp: Date.now()
347
+ });
348
+ }
349
+
350
+ debugLog(`[cleanup] Cleanup complete for ${conversationId}`);
351
+ }
352
+
314
353
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
315
354
  const rootDir = process.env.PORTABLE_EXE_DIR || __dirname;
316
355
  const PORT = process.env.PORT || 3000;
@@ -3237,7 +3276,7 @@ function serveFile(filePath, res, req) {
3237
3276
  'Content-Type': contentType,
3238
3277
  'Content-Length': stats.size,
3239
3278
  'ETag': etag,
3240
- 'Cache-Control': ['.js', '.css'].includes(ext) ? 'no-cache' : 'public, max-age=3600, must-revalidate'
3279
+ 'Cache-Control': ['.js', '.css'].includes(ext) ? 'public, max-age=0, must-revalidate' : 'public, max-age=3600, must-revalidate'
3241
3280
  };
3242
3281
  if (acceptsEncoding(req, 'br') && stats.size > 860) {
3243
3282
  const stream = fs.createReadStream(filePath);
@@ -3832,10 +3871,18 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
3832
3871
  });
3833
3872
  } finally {
3834
3873
  batcher.drain();
3835
- activeExecutions.delete(conversationId);
3874
+ // Use atomic cleanup but only if not in rate limit recovery
3836
3875
  if (!rateLimitState.has(conversationId)) {
3837
- queries.setIsStreaming(conversationId, false);
3876
+ cleanupExecution(conversationId);
3838
3877
  drainMessageQueue(conversationId);
3878
+ } else {
3879
+ // Rate limit in flight - keep execution entry for now, but clean process handle
3880
+ activeProcessesByConvId.delete(conversationId);
3881
+ const steeringTimeout = steeringTimeouts.get(conversationId);
3882
+ if (steeringTimeout) {
3883
+ clearTimeout(steeringTimeout);
3884
+ steeringTimeouts.delete(conversationId);
3885
+ }
3839
3886
  }
3840
3887
  }
3841
3888
  }
@@ -3871,6 +3918,16 @@ function scheduleRetry(conversationId, messageId, content, agentId, model, subAg
3871
3918
  .catch(err => {
3872
3919
  debugLog(`[rate-limit] Retry failed: ${err.message}`);
3873
3920
  console.error(`[rate-limit] Retry error for conv ${conversationId}:`, err);
3921
+ // Clean up state on retry failure
3922
+ cleanupExecution(conversationId);
3923
+ broadcastSync({
3924
+ type: 'streaming_error',
3925
+ sessionId: newSession.id,
3926
+ conversationId,
3927
+ error: `Rate limit retry failed: ${err.message}`,
3928
+ recoverable: false,
3929
+ timestamp: Date.now()
3930
+ });
3874
3931
  });
3875
3932
  }
3876
3933
 
@@ -3915,7 +3972,21 @@ function drainMessageQueue(conversationId) {
3915
3972
  activeExecutions.set(conversationId, { pid: null, startTime, sessionId: session.id, lastActivity: startTime });
3916
3973
 
3917
3974
  processMessageWithStreaming(conversationId, next.messageId, session.id, next.content, next.agentId, next.model, next.subAgent)
3918
- .catch(err => debugLog(`[queue] Error processing queued message: ${err.message}`));
3975
+ .catch(err => {
3976
+ debugLog(`[queue] Error processing queued message: ${err.message}`);
3977
+ // CRITICAL: Clean up state on error so next message can be retried
3978
+ cleanupExecution(conversationId);
3979
+ broadcastSync({
3980
+ type: 'streaming_error',
3981
+ sessionId: session.id,
3982
+ conversationId,
3983
+ error: `Queue processing failed: ${err.message}`,
3984
+ recoverable: true,
3985
+ timestamp: Date.now()
3986
+ });
3987
+ // Try to drain next message in queue
3988
+ setTimeout(() => drainMessageQueue(conversationId), 100);
3989
+ });
3919
3990
  }
3920
3991
 
3921
3992
 
@@ -4026,7 +4097,8 @@ const wsRouter = new WsRouter();
4026
4097
 
4027
4098
  registerConvHandlers(wsRouter, {
4028
4099
  queries, activeExecutions, messageQueues, rateLimitState,
4029
- broadcastSync, processMessageWithStreaming, activeProcessesByConvId, steeringTimeouts
4100
+ broadcastSync, processMessageWithStreaming, activeProcessesByConvId, steeringTimeouts,
4101
+ cleanupExecution
4030
4102
  });
4031
4103
 
4032
4104
  console.log('[INIT] About to call registerSessionHandlers, discoveredAgents.length:', discoveredAgents.length);
@@ -4151,7 +4223,8 @@ wsRouter.onLegacy((data, ws) => {
4151
4223
  try { ws.terminalProc.kill(); } catch(e) {}
4152
4224
  }
4153
4225
  try {
4154
- const pty = require('node-pty');
4226
+ const _req = createRequire(import.meta.url);
4227
+ const pty = _req('node-pty');
4155
4228
  const shell = process.env.SHELL || '/bin/bash';
4156
4229
  const cwd = data.cwd || process.env.STARTUP_CWD || process.env.HOME || '/';
4157
4230
  const proc = pty.spawn(shell, [], {
@@ -4178,7 +4251,6 @@ wsRouter.onLegacy((data, ws) => {
4178
4251
  sendWs(ws, ({ type: 'terminal_started', timestamp: Date.now() }));
4179
4252
  } catch (e) {
4180
4253
  console.error('[TERMINAL] Failed to spawn PTY, falling back to pipes:', e.message);
4181
- const { spawn } = require('child_process');
4182
4254
  const shell = process.env.SHELL || '/bin/bash';
4183
4255
  const cwd = data.cwd || process.env.STARTUP_CWD || process.env.HOME || '/';
4184
4256
  const proc = spawn(shell, ['-i'], { cwd, env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' }, stdio: ['pipe', 'pipe', 'pipe'] });
@@ -4540,10 +4612,12 @@ function onServerReady() {
4540
4612
  }, 6000);
4541
4613
  }).catch(err => console.error('[ACP] Startup error:', err.message));
4542
4614
 
4543
- const toolIds = ['cli-claude', 'cli-opencode', 'cli-gemini', 'cli-kilo', 'cli-codex', 'gm-cc', 'gm-oc', 'gm-gc', 'gm-kilo'];
4615
+ const toolIds = ['cli-claude', 'cli-opencode', 'cli-gemini', 'cli-kilo', 'cli-codex', 'cli-agent-browser', 'gm-cc', 'gm-oc', 'gm-gc', 'gm-kilo'];
4544
4616
  queries.initializeToolInstallations(toolIds.map(id => ({ id })));
4545
4617
  console.log('[TOOLS] Starting background provisioning...');
4546
- toolManager.autoProvision((evt) => {
4618
+
4619
+ // Create broadcast handler for tool events
4620
+ const toolBroadcaster = (evt) => {
4547
4621
  broadcastSync(evt);
4548
4622
  if (evt.type === 'tool_install_complete' || evt.type === 'tool_update_complete') {
4549
4623
  const d = evt.data || {};
@@ -4558,7 +4632,17 @@ function onServerReady() {
4558
4632
  queries.updateToolStatus(evt.toolId, { status: 'installed', version: d.installedVersion || null, installed_at: Date.now() });
4559
4633
  }
4560
4634
  }
4561
- }).catch(err => console.error('[TOOLS] Auto-provision error:', err.message));
4635
+ };
4636
+
4637
+ // Initial provisioning (blocks until complete)
4638
+ toolManager.autoProvision(toolBroadcaster)
4639
+ .catch(err => console.error('[TOOLS] Auto-provision error:', err.message))
4640
+ .then(() => {
4641
+ // Start periodic update checker AFTER initial provisioning completes
4642
+ // This runs in background and doesn't block GUI
4643
+ console.log('[TOOLS] Starting periodic update checker...');
4644
+ toolManager.startPeriodicUpdateCheck(toolBroadcaster);
4645
+ });
4562
4646
 
4563
4647
  ensureModelsDownloaded().then(async ok => {
4564
4648
  if (ok) console.log('[MODELS] Speech models ready');
@@ -157,6 +157,9 @@ class AgentGUIClient {
157
157
  this._recoverMissedChunks();
158
158
  this.updateSendButtonState();
159
159
  this.enablePromptArea();
160
+ if (this.state.currentConversation?.id) {
161
+ this.updateBusyPromptArea(this.state.currentConversation.id);
162
+ }
160
163
  this.emit('ws:connected');
161
164
  // Check if server was updated while client was loaded - reload if version changed
162
165
  if (window.__SERVER_VERSION) {
@@ -2045,8 +2048,6 @@ class AgentGUIClient {
2045
2048
  </div>
2046
2049
  </div>
2047
2050
  `;
2048
- // Keep loading spinner visible during hydration
2049
- this.showLoadingSpinner();
2050
2051
  }
2051
2052
 
2052
2053
  async streamToConversation(conversationId, prompt, agentId, model, subAgent) {
@@ -2084,6 +2085,7 @@ class AgentGUIClient {
2084
2085
 
2085
2086
  if (result.queued) {
2086
2087
  console.log('Message queued, position:', result.queuePosition);
2088
+ this.enableControls();
2087
2089
  return;
2088
2090
  }
2089
2091
 
@@ -2491,22 +2493,20 @@ class AgentGUIClient {
2491
2493
  }
2492
2494
 
2493
2495
  /**
2494
- * Disable UI controls during streaming
2495
- * NOTE: Prompt area is IMMUTABLE - always enabled while connected.
2496
- * Streaming state is rendered via queue/steer buttons, not input disabling.
2496
+ * Disable UI controls during execution - prevents double-sends
2497
2497
  */
2498
2498
  disableControls() {
2499
- // IMMUTABLE: Prompt state managed only by syncPromptState() based on WebSocket connection
2500
- // Never disable input during streaming - use queue/steer visibility instead
2499
+ if (this.ui.sendButton) this.ui.sendButton.disabled = true;
2501
2500
  }
2502
2501
 
2503
2502
  /**
2504
- * Enable UI controls
2505
- * NOTE: Prompt area is always enabled when connected.
2503
+ * Enable UI controls after execution completes or fails
2506
2504
  */
2507
2505
  enableControls() {
2508
- // IMMUTABLE: Prompt state managed only by syncPromptState() based on WebSocket connection
2509
- // Never disable input during streaming - use queue/steer visibility instead
2506
+ if (this.ui.sendButton) {
2507
+ this.ui.sendButton.disabled = !this.wsManager?.isConnected;
2508
+ }
2509
+ this.updateBusyPromptArea(this.state.currentConversation?.id);
2510
2510
  }
2511
2511
 
2512
2512
  /**
@@ -2686,10 +2686,6 @@ class AgentGUIClient {
2686
2686
  this.conversationCache.delete(conversationId);
2687
2687
  this.syncPromptState(conversationId);
2688
2688
  this.restoreScrollPosition(conversationId);
2689
- // Hydration complete - hide loading spinner
2690
- this.hideLoadingSpinner();
2691
- // Prompt state is immutable: computed from shouldResumeStreaming via syncPromptState
2692
- // Do not call enableControls/disableControls here - prompt state is determined by streaming status
2693
2689
  return;
2694
2690
  }
2695
2691
  }
@@ -2700,9 +2696,8 @@ class AgentGUIClient {
2700
2696
 
2701
2697
  let fullData;
2702
2698
  try {
2703
- // Load only recent chunks initially (lazy load older ones)
2704
- // Use chunkLimit of 50 to make page load faster
2705
2699
  fullData = await window.wsClient.rpc('conv.full', { id: conversationId, chunkLimit: 50 });
2700
+ if (convSignal.aborted) return;
2706
2701
  } catch (e) {
2707
2702
  if (e.code === 404) {
2708
2703
  console.warn('Conversation no longer exists:', conversationId);
@@ -2724,6 +2719,7 @@ class AgentGUIClient {
2724
2719
  }
2725
2720
  throw e;
2726
2721
  }
2722
+ if (convSignal.aborted) return;
2727
2723
  const { conversation, isActivelyStreaming, latestSession, chunks: rawChunks, totalChunks, messages: allMessages } = fullData;
2728
2724
 
2729
2725
  window.ConversationState?.selectConversation(conversationId, 'server_load', 1);
@@ -2736,18 +2732,32 @@ class AgentGUIClient {
2736
2732
  ...chunk,
2737
2733
  block: typeof chunk.data === 'string' ? JSON.parse(chunk.data) : chunk.data
2738
2734
  }));
2739
- const userMessages = (allMessages || []).filter(m => m.role === 'user');
2735
+
2736
+ // Fetch queue to exclude queued messages from being rendered as regular user messages
2737
+ let queuedMessageIds = new Set();
2738
+ try {
2739
+ const { queue } = await window.wsClient.rpc('q.ls', { id: conversationId });
2740
+ if (queue && queue.length > 0) {
2741
+ queuedMessageIds = new Set(queue.map(q => q.messageId));
2742
+ }
2743
+ } catch (e) {
2744
+ console.warn('Failed to fetch queue:', e.message);
2745
+ }
2746
+
2747
+ // Filter out queued messages from user messages - they'll be rendered in queue indicator instead
2748
+ const userMessages = (allMessages || []).filter(m => m.role === 'user' && !queuedMessageIds.has(m.id));
2740
2749
  const hasMoreChunks = totalChunks && chunks.length < totalChunks;
2741
2750
 
2742
2751
  const clientKnowsStreaming = this.state.streamingConversations.has(conversationId);
2743
2752
  const shouldResumeStreaming = latestSession &&
2744
2753
  (latestSession.status === 'active' || latestSession.status === 'pending');
2745
2754
 
2746
- // IMMUTABLE: Update streaming state and disable prompt atomically
2747
2755
  if (shouldResumeStreaming) {
2748
2756
  this.state.streamingConversations.set(conversationId, true);
2757
+ window.dispatchEvent(new CustomEvent('ws-message', { detail: { type: 'streaming_start', conversationId, sessionId: latestSession?.id } }));
2749
2758
  } else {
2750
2759
  this.state.streamingConversations.delete(conversationId);
2760
+ window.dispatchEvent(new CustomEvent('ws-message', { detail: { type: 'streaming_complete', conversationId } }));
2751
2761
  }
2752
2762
 
2753
2763
  if (this.ui.messageInput) {
@@ -2948,12 +2958,13 @@ class AgentGUIClient {
2948
2958
 
2949
2959
  this.restoreScrollPosition(conversationId);
2950
2960
  this.setupScrollUpDetection(conversationId);
2951
- // Hydration complete - hide loading spinner
2952
- this.hideLoadingSpinner();
2961
+
2962
+ // Fetch and display queue items so queued messages show in yellow blocks, not as user messages
2963
+ this.fetchAndRenderQueue(conversationId);
2964
+
2953
2965
  }
2954
2966
  } catch (error) {
2955
2967
  if (error.name === 'AbortError') return;
2956
- this.hideLoadingSpinner();
2957
2968
  console.error('Failed to load conversation messages:', error);
2958
2969
  // Resume from last successful conversation if available, or fall back to any available conversation
2959
2970
  const fallbackConv = prevConversationId ? prevConversationId : availableFallback?.id;
@@ -3198,7 +3209,7 @@ class AgentGUIClient {
3198
3209
  }
3199
3210
 
3200
3211
  saveAgentAndModelToConversation() {
3201
- const convId = this.state.currentConversation;
3212
+ const convId = this.state.currentConversation?.id;
3202
3213
  if (!convId || this._agentLocked) return;
3203
3214
  const agentId = this.getEffectiveAgentId();
3204
3215
  const subAgent = this.getEffectiveSubAgent();
@@ -3338,6 +3349,7 @@ let agentGUIClient = null;
3338
3349
  document.addEventListener('DOMContentLoaded', async () => {
3339
3350
  try {
3340
3351
  agentGUIClient = new AgentGUIClient();
3352
+ window.agentGuiClient = agentGUIClient;
3341
3353
  await agentGUIClient.init();
3342
3354
  console.log('AgentGUI ready');
3343
3355
  } catch (error) {
@@ -433,8 +433,11 @@ class ConversationManager {
433
433
 
434
434
  this._updateConversations(convList, 'poll');
435
435
 
436
+ const clientStreamingMap = window.agentGuiClient?.state?.streamingConversations;
436
437
  for (const conv of this.conversations) {
437
- if (conv.isStreaming === 1 || conv.isStreaming === true) {
438
+ const serverStreaming = conv.isStreaming === 1 || conv.isStreaming === true;
439
+ const clientStreaming = clientStreamingMap ? clientStreamingMap.has(conv.id) : false;
440
+ if (serverStreaming || clientStreaming) {
438
441
  this.streamingConversations.add(conv.id);
439
442
  } else {
440
443
  this.streamingConversations.delete(conv.id);
@@ -39,6 +39,7 @@
39
39
  }
40
40
  function closeSidebar() {
41
41
  sidebar.classList.remove('mobile-visible');
42
+ sidebar.classList.add('collapsed'); // Ensure sidebar is hidden on close
42
43
  if (overlay) overlay.classList.remove('visible');
43
44
  }
44
45
  if (toggleBtn) toggleBtn.addEventListener('click', function(e) { e.stopPropagation(); toggleSidebar(); });
@@ -1,39 +1,31 @@
1
1
  (function() {
2
- var ws = null;
3
2
  var term = null;
4
3
  var fitAddon = null;
5
4
  var termActive = false;
6
5
  var BASE = window.__BASE_URL || '';
7
-
8
- function getWsUrl() {
9
- var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
10
- return proto + '//' + location.host + BASE + '/sync';
11
- }
6
+ var _wsListener = null;
12
7
 
13
8
  function getCwd() {
14
9
  try {
15
- // Try conversation manager first
16
10
  if (window.conversationManager && window.conversationManager.activeId) {
17
11
  var mgr = window.conversationManager;
18
- var id = mgr.activeId;
19
- if (mgr.conversations) {
20
- var conv = mgr.conversations.find(function(c) { return c.id === id; });
21
- if (conv && conv.workingDirectory) return conv.workingDirectory;
22
- }
12
+ var conv = mgr.conversations && mgr.conversations.find(function(c) { return c.id === mgr.activeId; });
13
+ if (conv && conv.workingDirectory) return conv.workingDirectory;
23
14
  }
24
- // Fallback to global currentConversation
25
- if (window.currentConversation) {
26
- var convId = window.currentConversation;
27
- if (window.conversationManager && window.conversationManager.conversations) {
28
- var convList = window.conversationManager.conversations;
29
- var match = convList.find(function(c) { return c.id === convId; });
30
- if (match && match.workingDirectory) return match.workingDirectory;
31
- }
15
+ if (window.currentConversation && window.conversationManager && window.conversationManager.conversations) {
16
+ var match = window.conversationManager.conversations.find(function(c) { return c.id === window.currentConversation; });
17
+ if (match && match.workingDirectory) return match.workingDirectory;
32
18
  }
33
19
  } catch (_) {}
34
20
  return undefined;
35
21
  }
36
22
 
23
+ function wsSend(obj) {
24
+ if (window.wsManager && window.wsManager.send) {
25
+ window.wsManager.send(obj);
26
+ }
27
+ }
28
+
37
29
  function ensureTerm() {
38
30
  var output = document.getElementById('terminalOutput');
39
31
  if (!output) return false;
@@ -60,16 +52,12 @@
60
52
  fitAddon.fit();
61
53
 
62
54
  term.onData(function(data) {
63
- if (ws && ws.readyState === WebSocket.OPEN) {
64
- var encoded = btoa(unescape(encodeURIComponent(data)));
65
- ws.send(JSON.stringify({ type: 'terminal_input', data: encoded }));
66
- }
55
+ var encoded = btoa(unescape(encodeURIComponent(data)));
56
+ wsSend({ type: 'terminal_input', data: encoded });
67
57
  });
68
58
 
69
59
  term.onResize(function(size) {
70
- if (ws && ws.readyState === WebSocket.OPEN) {
71
- ws.send(JSON.stringify({ type: 'terminal_resize', cols: size.cols, rows: size.rows }));
72
- }
60
+ wsSend({ type: 'terminal_resize', cols: size.cols, rows: size.rows });
73
61
  });
74
62
 
75
63
  var resizeTimer;
@@ -78,57 +66,42 @@
78
66
  try { fitAddon.fit(); } catch(_) {}
79
67
  clearTimeout(resizeTimer);
80
68
  resizeTimer = setTimeout(function() {
81
- if (term && ws && ws.readyState === WebSocket.OPEN) {
82
- ws.send(JSON.stringify({ type: 'terminal_resize', cols: term.cols, rows: term.rows }));
83
- }
69
+ if (term) wsSend({ type: 'terminal_resize', cols: term.cols, rows: term.rows });
84
70
  }, 200);
85
71
  }
86
72
  });
87
73
 
88
- output.addEventListener('click', function() {
74
+ document.getElementById('terminalOutput').addEventListener('click', function() {
89
75
  if (term && term.focus) term.focus();
90
76
  });
91
77
 
92
78
  return true;
93
79
  }
94
80
 
95
- function connectAndStart() {
96
- var cwd = getCwd();
97
- if (ws && ws.readyState === WebSocket.OPEN) {
98
- var dims = term ? { cols: term.cols, rows: term.rows } : { cols: 80, rows: 24 };
99
- ws.send(JSON.stringify({ type: 'terminal_start', cwd: cwd, cols: dims.cols, rows: dims.rows }));
100
- setTimeout(function() { if (term && term.focus) term.focus(); }, 100);
101
- return;
102
- }
103
- if (ws && ws.readyState === WebSocket.CONNECTING) {
104
- return;
105
- }
106
-
107
- ws = new WebSocket(getWsUrl());
108
- ws.onopen = function() {
109
- var dims = term ? { cols: term.cols, rows: term.rows } : { cols: 80, rows: 24 };
110
- ws.send(JSON.stringify({ type: 'terminal_start', cwd: cwd, cols: dims.cols, rows: dims.rows }));
111
- setTimeout(function() { if (term && term.focus) term.focus(); }, 100);
112
- };
113
- ws.onmessage = function(e) {
114
- try {
115
- var msg = JSON.parse(e.data);
116
- if (msg.type === 'terminal_output' && term) {
117
- var raw = msg.encoding === 'base64'
118
- ? decodeURIComponent(escape(atob(msg.data)))
119
- : msg.data;
120
- term.write(raw);
121
- } else if (msg.type === 'terminal_exit' && term) {
122
- term.write('\r\n[Process exited with code ' + msg.code + ']\r\n');
123
- if (termActive) setTimeout(connectAndStart, 2000);
124
- }
125
- } catch(_) {}
126
- };
127
- ws.onclose = function() {
128
- ws = null;
129
- if (termActive) setTimeout(connectAndStart, 2000);
81
+ function installWsListener() {
82
+ if (_wsListener || !window.wsManager) return;
83
+ _wsListener = function(msg) {
84
+ if (!termActive) return;
85
+ if (msg.type === 'terminal_output' && term) {
86
+ var raw = msg.encoding === 'base64'
87
+ ? decodeURIComponent(escape(atob(msg.data)))
88
+ : msg.data;
89
+ term.write(raw);
90
+ } else if (msg.type === 'terminal_exit' && term) {
91
+ term.write('\r\n[Process exited with code ' + msg.code + ']\r\n');
92
+ if (termActive) setTimeout(startSession, 2000);
93
+ }
130
94
  };
131
- ws.onerror = function() {};
95
+ window.wsManager.on('message', _wsListener);
96
+ }
97
+
98
+ function startSession() {
99
+ if (!window.wsManager) return;
100
+ installWsListener();
101
+ var cwd = getCwd();
102
+ var dims = term ? { cols: term.cols, rows: term.rows } : { cols: 80, rows: 24 };
103
+ wsSend({ type: 'terminal_start', cwd: cwd, cols: dims.cols, rows: dims.rows });
104
+ setTimeout(function() { if (term && term.focus) term.focus(); }, 100);
132
105
  }
133
106
 
134
107
  function startTerminal() {
@@ -137,19 +110,21 @@
137
110
  return;
138
111
  }
139
112
  termActive = true;
140
- connectAndStart();
113
+ if (window.wsManager && window.wsManager.isConnected) {
114
+ startSession();
115
+ } else if (window.wsManager) {
116
+ var onConnected = function() {
117
+ window.wsManager.off('connected', onConnected);
118
+ startSession();
119
+ };
120
+ window.wsManager.on('connected', onConnected);
121
+ }
141
122
  setTimeout(function() { if (fitAddon) try { fitAddon.fit(); } catch(_) {} }, 100);
142
123
  }
143
124
 
144
125
  function stopTerminal() {
145
126
  termActive = false;
146
- if (ws && ws.readyState === WebSocket.OPEN) {
147
- ws.send(JSON.stringify({ type: 'terminal_stop' }));
148
- }
149
- if (ws) {
150
- ws.close();
151
- ws = null;
152
- }
127
+ wsSend({ type: 'terminal_stop' });
153
128
  }
154
129
 
155
130
  function initTerminalEarly() {
@@ -172,7 +147,7 @@
172
147
  return;
173
148
  }
174
149
  termActive = true;
175
- connectAndStart();
150
+ startSession();
176
151
  setTimeout(function() { if (fitAddon) try { fitAddon.fit(); } catch(_) {} }, 50);
177
152
  setTimeout(function() { if (fitAddon) try { fitAddon.fit(); } catch(_) {} }, 300);
178
153
  } else if (termActive) {
@@ -180,15 +155,12 @@
180
155
  }
181
156
  });
182
157
 
183
- // Restart terminal in the new conversation's cwd when conversation switches while terminal is active
184
- window.addEventListener('conversation-changed', function(e) {
158
+ window.addEventListener('conversation-changed', function() {
185
159
  if (!termActive) return;
186
160
  var cwd = getCwd();
187
- if (ws && ws.readyState === WebSocket.OPEN) {
188
- var dims = term ? { cols: term.cols, rows: term.rows } : { cols: 80, rows: 24 };
189
- ws.send(JSON.stringify({ type: 'terminal_start', cwd: cwd, cols: dims.cols, rows: dims.rows }));
190
- if (term) term.write('\r\n\x1b[33m[Switched to: ' + (cwd || '/') + ']\x1b[0m\r\n');
191
- }
161
+ var dims = term ? { cols: term.cols, rows: term.rows } : { cols: 80, rows: 24 };
162
+ wsSend({ type: 'terminal_start', cwd: cwd, cols: dims.cols, rows: dims.rows });
163
+ if (term) term.write('\r\n\x1b[33m[Switched to: ' + (cwd || '/') + ']\x1b[0m\r\n');
192
164
  });
193
165
 
194
166
  window.terminalModule = {