agentgui 1.0.616 → 1.0.618

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.
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * Patch script to fix Windows path duplication issue in fsbrowse
4
+ * and sync fsbrowse styling with AgentGUI dark mode theme
4
5
  * Fixes: Error ENOENT: no such file or directory, scandir 'C:\C:\dev'
5
6
  */
6
7
 
@@ -54,10 +55,10 @@ try {
54
55
  // to avoid duplication like C:\\C:\\dev
55
56
  if (baseDriveLetter && sanitizedIsAbsoluteOnDrive && sanitizedDriveLetter === baseDriveLetter) {
56
57
  // Remove drive letter and leading slashes to make it relative
57
- let relativePath = sanitized;
58
- if (/^[A-Z]:/i.test(relativePath)) {
59
- relativePath = relativePath.substring(2);
60
- if (relativePath[0] === '/' || relativePath[0] === String.fromCharCode(92)) relativePath = relativePath.substring(1);
58
+ let relativePath = sanitized;
59
+ if (/^[A-Z]:/i.test(relativePath)) {
60
+ relativePath = relativePath.substring(2);
61
+ if (relativePath[0] === '/' || relativePath[0] === String.fromCharCode(92)) relativePath = relativePath.substring(1);
61
62
  }
62
63
  fullPath = path.resolve(normalizedBase, relativePath);
63
64
  } else {
@@ -90,3 +91,129 @@ try {
90
91
  console.error('[PATCH] Error applying fsbrowse patch:', err.message);
91
92
  process.exit(1);
92
93
  }
94
+
95
+ // Patch fsbrowse CSS for dark mode theme sync
96
+ const fsbrowseCSSPath = path.join(__dirname, '..', 'node_modules', 'fsbrowse', 'public', 'style.css');
97
+
98
+ if (fs.existsSync(fsbrowseCSSPath)) {
99
+ try {
100
+ let cssContent = fs.readFileSync(fsbrowseCSSPath, 'utf8');
101
+
102
+ // Check if dark mode CSS is already patched
103
+ if (cssContent.includes('html.dark {')) {
104
+ console.log('[PATCH] fsbrowse dark mode CSS already patched');
105
+ } else {
106
+ // Inject dark mode CSS rules
107
+ const darkModeCSS = `/* Light mode - explicit */
108
+ html.light {
109
+ --primary: #3b82f6;
110
+ --primary-dark: #2563eb;
111
+ --secondary: #6b7280;
112
+ --border: #e5e7eb;
113
+ --bg: #ffffff;
114
+ --bg-alt: #f9fafb;
115
+ --text: #111827;
116
+ --text-light: #6b7280;
117
+ --danger: #ef4444;
118
+ }
119
+
120
+ /* Dark mode - explicit, matches AgentGUI grey dark theme */
121
+ html.dark {
122
+ --primary: #737373;
123
+ --primary-dark: #525252;
124
+ --secondary: #a3a3a3;
125
+ --border: #333333;
126
+ --bg: #1a1a1a;
127
+ --bg-alt: #242424;
128
+ --text: #e5e5e5;
129
+ --text-light: #a3a3a3;
130
+ --danger: #ef4444;
131
+ }
132
+
133
+ /* Fallback: media query for dark mode preference */
134
+ @media (prefers-color-scheme: dark) {
135
+ :root:not(.light) {
136
+ --primary: #60a5fa;
137
+ --primary-dark: #3b82f6;
138
+ --secondary: #9ca3af;
139
+ --border: #374151;
140
+ --bg: #111827;
141
+ --bg-alt: #1f2937;
142
+ --text: #f3f4f6;
143
+ --text-light: #9ca3af;
144
+ --danger: #f87171;
145
+ }
146
+ }`;
147
+
148
+ // Find the closing brace of :root and insert after it
149
+ cssContent = cssContent.replace(
150
+ /:root \{[\s\S]*?\}\s*@media/,
151
+ match => match.replace('@media', darkModeCSS + '\n@media')
152
+ );
153
+
154
+ fs.writeFileSync(fsbrowseCSSPath, cssContent, 'utf8');
155
+ console.log('[PATCH] fsbrowse dark mode CSS patched successfully');
156
+ }
157
+ } catch (err) {
158
+ console.warn('[PATCH] Could not patch fsbrowse CSS:', err.message);
159
+ }
160
+ }
161
+
162
+ // Patch fsbrowse app.js for theme sync
163
+ const fsbrowseAppJSPath = path.join(__dirname, '..', 'node_modules', 'fsbrowse', 'public', 'app.js');
164
+
165
+ if (fs.existsSync(fsbrowseAppJSPath)) {
166
+ try {
167
+ let appContent = fs.readFileSync(fsbrowseAppJSPath, 'utf8');
168
+
169
+ // Check if theme sync is already patched
170
+ if (appContent.includes('setupThemeSync')) {
171
+ console.log('[PATCH] fsbrowse theme sync already patched');
172
+ } else {
173
+ // Inject setupThemeSync call and method
174
+ const themeSyncMethod = `
175
+ setupThemeSync() {
176
+ // Sync theme from parent window/localStorage if available
177
+ const syncTheme = () => {
178
+ const theme = localStorage.getItem('gmgui-theme') ||
179
+ (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
180
+ document.documentElement.className = theme;
181
+ document.documentElement.setAttribute('data-theme', theme);
182
+ };
183
+
184
+ syncTheme();
185
+
186
+ // Watch for storage changes from other tabs/windows
187
+ window.addEventListener('storage', e => {
188
+ if (e.key === 'gmgui-theme') syncTheme();
189
+ });
190
+
191
+ // Watch for media query changes
192
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', syncTheme);
193
+ },`;
194
+
195
+ // Add setupThemeSync call to init()
196
+ appContent = appContent.replace(
197
+ 'async init() {',
198
+ 'async init() {\n this.setupThemeSync();'
199
+ );
200
+
201
+ // Add setupThemeSync method after init()
202
+ appContent = appContent.replace(
203
+ 'async init() {\n this.setupThemeSync();\n this.setupDragDrop();',
204
+ 'async init() {\n this.setupThemeSync();\n this.setupDragDrop();'
205
+ );
206
+
207
+ // Insert the method after the api() method
208
+ appContent = appContent.replace(
209
+ 'api(path) {\n return `${this.basePath}${path}`;\n },',
210
+ 'api(path) {\n return `${this.basePath}${path}`;\n },' + themeSyncMethod
211
+ );
212
+
213
+ fs.writeFileSync(fsbrowseAppJSPath, appContent, 'utf8');
214
+ console.log('[PATCH] fsbrowse theme sync patched successfully');
215
+ }
216
+ } catch (err) {
217
+ console.warn('[PATCH] Could not patch fsbrowse app.js:', err.message);
218
+ }
219
+ }
package/server.js CHANGED
@@ -372,37 +372,63 @@ expressApp.post(BASE_URL + '/api/upload/:conversationId', (req, res) => {
372
372
  }
373
373
  });
374
374
 
375
+ // Cache fsbrowse routers per conversation to ensure API calls work
376
+ const fsbrowseRouters = new Map();
377
+
375
378
  // fsbrowse file browser - mounted per conversation workingDirectory
376
379
  // Route: /gm/files/:conversationId/*
377
380
  expressApp.use(BASE_URL + '/files/:conversationId', (req, res, next) => {
378
- const conv = queries.getConversation(req.params.conversationId);
381
+ const convId = req.params.conversationId;
382
+ const conv = queries.getConversation(convId);
379
383
  if (!conv || !conv.workingDirectory) {
380
384
  return res.status(404).json({ error: 'Conversation not found or no working directory' });
381
385
  }
386
+
382
387
  // Normalize the working directory path to avoid Windows path duplication issues
383
388
  const normalizedWorkingDir = path.resolve(conv.workingDirectory);
384
- // Create a fresh fsbrowse router for this conversation's directory
385
- const router = fsbrowse({ baseDir: normalizedWorkingDir, name: 'Files' });
386
- // Strip the conversationId param from the path before passing to fsbrowse
387
- req.baseUrl = BASE_URL + '/files/' + req.params.conversationId;
389
+
390
+ // Get or create cached fsbrowse router for this conversation
391
+ let router = fsbrowseRouters.get(convId);
392
+ if (!router) {
393
+ router = fsbrowse({ baseDir: normalizedWorkingDir, name: 'Files' });
394
+ fsbrowseRouters.set(convId, router);
395
+ }
396
+
397
+ // Set baseUrl before calling the router
398
+ req.baseUrl = BASE_URL + '/files/' + convId;
399
+ req.url = req.url.replace(new RegExp(`^${BASE_URL}/files/${convId}`), '');
400
+
388
401
  router(req, res, next);
389
402
  });
390
403
 
391
404
  function findCommand(cmd) {
392
405
  const isWindows = os.platform() === 'win32';
393
406
  const localBin = path.join(path.dirname(fileURLToPath(import.meta.url)), 'node_modules', '.bin', isWindows ? cmd + '.cmd' : cmd);
394
- if (fs.existsSync(localBin)) return localBin;
407
+ if (fs.existsSync(localBin)) {
408
+ console.log(`[agent-discovery] Found ${cmd} in local node_modules`);
409
+ return localBin;
410
+ }
395
411
  try {
412
+ // Increase timeout to 10 seconds to handle slower systems and running agents
413
+ const timeoutMs = 10000;
396
414
  if (isWindows) {
397
- const result = execSync(`where ${cmd}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
398
- return result.split('\n')[0].trim();
415
+ const result = execSync(`where ${cmd}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'], timeout: timeoutMs }).trim();
416
+ if (result) {
417
+ console.log(`[agent-discovery] Found ${cmd} in PATH`);
418
+ return result.split('\n')[0].trim();
419
+ }
399
420
  } else {
400
- const result = execSync(`which ${cmd}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
401
- return result;
421
+ const result = execSync(`which ${cmd}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'], timeout: timeoutMs }).trim();
422
+ if (result) {
423
+ console.log(`[agent-discovery] Found ${cmd} in PATH`);
424
+ return result;
425
+ }
402
426
  }
403
- } catch (_) {
427
+ } catch (err) {
428
+ console.log(`[agent-discovery] ${cmd} not found or timed out`);
404
429
  return null;
405
430
  }
431
+ return null;
406
432
  }
407
433
 
408
434
  async function queryACPServerAgents(baseUrl) {
@@ -479,7 +505,11 @@ function discoverAgents() {
479
505
  if (result) {
480
506
  agents.push({ id: bin.id, name: bin.name, icon: bin.icon, path: result, protocol: bin.protocol });
481
507
  } else if (bin.npxPackage) {
508
+ // For npx-launchable packages (including claude-code as fallback)
482
509
  agents.push({ id: bin.id, name: bin.name, icon: bin.icon, path: null, protocol: bin.protocol, npxPackage: bin.npxPackage, npxLaunchable: true });
510
+ } else if (bin.id === 'claude-code') {
511
+ // Ensure Claude Code is always available as an npx-launchable agent
512
+ agents.push({ id: bin.id, name: bin.name, icon: bin.icon, path: null, protocol: bin.protocol, npxPackage: '@anthropic-ai/claude-code', npxLaunchable: true });
483
513
  }
484
514
  }
485
515
  return agents;
@@ -497,9 +527,23 @@ async function discoverExternalACPServers() {
497
527
  return externalAgents;
498
528
  }
499
529
 
500
- const discoveredAgents = discoverAgents();
530
+ let discoveredAgents = [];
501
531
  initializeDescriptors(discoveredAgents);
502
532
 
533
+ // Agent discovery happens asynchronously in background to not block startup
534
+ async function initializeAgentDiscovery() {
535
+ try {
536
+ discoveredAgents = discoverAgents();
537
+ initializeDescriptors(discoveredAgents);
538
+ console.log('[AGENTS] Discovered:', discoveredAgents.map(a => ({ id: a.id, found: !!a.path })));
539
+ } catch (err) {
540
+ console.error('[AGENTS] Discovery error:', err.message);
541
+ }
542
+ }
543
+
544
+ // Start immediately but don't wait for it
545
+ initializeAgentDiscovery().catch(() => {});
546
+
503
547
  const modelCache = new Map();
504
548
 
505
549
  async function getModelsForAgent(agentId) {
@@ -1031,11 +1075,23 @@ const server = http.createServer(async (req, res) => {
1031
1075
 
1032
1076
  if (req.url === '/') { res.writeHead(302, { Location: BASE_URL + '/' }); res.end(); return; }
1033
1077
 
1034
- if (!req.url.startsWith(BASE_URL + '/') && req.url !== BASE_URL) {
1078
+ // Handle requests with or without BASE_URL prefix (for reverse proxy compatibility)
1079
+ let routePath = req.url;
1080
+ if (req.url.startsWith(BASE_URL + '/')) {
1081
+ routePath = req.url.slice(BASE_URL.length);
1082
+ } else if (req.url === BASE_URL) {
1083
+ routePath = '/';
1084
+ } else if (req.url.startsWith('/api/') || req.url.startsWith('/js/') || req.url.startsWith('/css/') ||
1085
+ req.url.startsWith('/vendor/') || req.url.startsWith('/sync') || req.url === '/' ||
1086
+ req.url.startsWith('/conversations/')) {
1087
+ // Allow requests without BASE_URL prefix for static files and known routes
1088
+ // This supports reverse proxies that strip the BASE_URL prefix
1089
+ routePath = req.url;
1090
+ } else {
1035
1091
  res.writeHead(404); res.end('Not found'); return;
1036
1092
  }
1037
1093
 
1038
- const routePath = req.url.slice(BASE_URL.length) || '/';
1094
+ routePath = routePath || '/';
1039
1095
 
1040
1096
  try {
1041
1097
  // Remove query parameters from routePath for matching
@@ -1833,21 +1889,44 @@ const server = http.createServer(async (req, res) => {
1833
1889
 
1834
1890
  if (pathOnly === '/api/tools' && req.method === 'GET') {
1835
1891
  console.log('[TOOLS-API] Handling GET /api/tools');
1836
- const tools = await toolManager.getAllToolsAsync();
1837
- const result = tools.map((t) => ({
1838
- id: t.id,
1839
- name: t.name,
1840
- pkg: t.pkg,
1841
- category: t.category || 'plugin',
1842
- installed: t.installed,
1843
- status: t.installed ? (t.isUpToDate ? 'installed' : 'needs_update') : 'not_installed',
1844
- isUpToDate: t.isUpToDate,
1845
- upgradeNeeded: t.upgradeNeeded,
1846
- hasUpdate: t.upgradeNeeded && t.installed,
1847
- installedVersion: t.installedVersion,
1848
- publishedVersion: t.publishedVersion
1849
- }));
1850
- sendJSON(req, res, 200, { tools: result });
1892
+ try {
1893
+ // Return immediately with cached data (non-blocking) - skip network version checks
1894
+ const tools = await Promise.race([
1895
+ toolManager.getAllToolsAsync(true), // skipPublishedVersion=true for fast response
1896
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000))
1897
+ ]);
1898
+ const result = tools.map((t) => ({
1899
+ id: t.id,
1900
+ name: t.name,
1901
+ pkg: t.pkg,
1902
+ category: t.category || 'plugin',
1903
+ installed: t.installed,
1904
+ status: t.installed ? (t.isUpToDate ? 'installed' : 'needs_update') : 'not_installed',
1905
+ isUpToDate: t.isUpToDate,
1906
+ upgradeNeeded: t.upgradeNeeded,
1907
+ hasUpdate: t.upgradeNeeded && t.installed,
1908
+ installedVersion: t.installedVersion,
1909
+ publishedVersion: t.publishedVersion
1910
+ }));
1911
+ sendJSON(req, res, 200, { tools: result });
1912
+ } catch (err) {
1913
+ console.log('[TOOLS-API] Error getting tools, returning cached status:', err.message);
1914
+ // Return synchronously cached tool status - this provides immediate response with last-known status
1915
+ const tools = toolManager.getAllToolsSync().map((t) => ({
1916
+ id: t.id,
1917
+ name: t.name,
1918
+ pkg: t.pkg,
1919
+ category: t.category || 'plugin',
1920
+ installed: t.installed || false,
1921
+ status: (t.installed) ? (t.isUpToDate ? 'installed' : 'needs_update') : 'not_installed',
1922
+ isUpToDate: t.isUpToDate || false,
1923
+ upgradeNeeded: t.upgradeNeeded || false,
1924
+ hasUpdate: (t.upgradeNeeded && t.installed) || false,
1925
+ installedVersion: t.installedVersion || null,
1926
+ publishedVersion: t.publishedVersion || null
1927
+ }));
1928
+ sendJSON(req, res, 200, { tools });
1929
+ }
1851
1930
  return;
1852
1931
  }
1853
1932
 
@@ -3425,6 +3504,7 @@ function serveFile(filePath, res, req) {
3425
3504
  const baseTag = `<script>window.__BASE_URL='${BASE_URL}';</script>`;
3426
3505
  content = content.replace('<head>', `<head>\n <base href="${BASE_URL}/">\n ` + baseTag);
3427
3506
  content = content.replace(/(href|src)="vendor\//g, `$1="${BASE_URL}/vendor/`);
3507
+ content = content.replace(/(src)="\/gm\/js\//g, `$1="${BASE_URL}/js/`);
3428
3508
  if (watch) {
3429
3509
  content += `\n<script>(function(){const ws=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'${BASE_URL}/hot-reload');ws.onmessage=e=>{if(JSON.parse(e.data).type==='reload')location.reload()};})();</script>`;
3430
3510
  }
@@ -4155,6 +4235,7 @@ const BROADCAST_TYPES = new Set([
4155
4235
  'streaming_start', 'streaming_progress', 'streaming_complete', 'streaming_error',
4156
4236
  'tool_install_started', 'tool_install_progress', 'tool_install_complete', 'tool_install_failed',
4157
4237
  'tool_update_progress', 'tool_update_complete', 'tool_update_failed',
4238
+ 'tool_status_update',
4158
4239
  'tools_update_started', 'tools_update_complete', 'tools_refresh_complete',
4159
4240
  'pm2_monit_update', 'pm2_monitoring_started', 'pm2_monitoring_stopped',
4160
4241
  'pm2_list_response', 'pm2_start_response', 'pm2_stop_response',
@@ -4219,7 +4300,7 @@ registerUtilHandlers(wsRouter, {
4219
4300
  broadcastSync, getSpeech, getProviderConfigs, saveProviderConfig,
4220
4301
  startGeminiOAuth, exchangeGeminiOAuthCode,
4221
4302
  geminiOAuthState: () => geminiOAuthState,
4222
- STARTUP_CWD, activeScripts, voiceCacheManager, toolManager
4303
+ STARTUP_CWD, activeScripts, voiceCacheManager, toolManager, discoveredAgents
4223
4304
  });
4224
4305
 
4225
4306
  wsRouter.onLegacy((data, ws) => {
@@ -4508,13 +4589,12 @@ server.on('error', (err) => {
4508
4589
  function recoverStaleSessions() {
4509
4590
  try {
4510
4591
  const now = Date.now();
4592
+ const RESUME_WINDOW_MS = 600000; // 10 minutes
4511
4593
 
4512
4594
  const resumable = new Set();
4513
- const resumableConvs = queries.getResumableConversations ? queries.getResumableConversations() : [];
4595
+ const resumableConvs = queries.getResumableConversations ? queries.getResumableConversations(RESUME_WINDOW_MS) : [];
4514
4596
  for (const conv of resumableConvs) {
4515
- if (conv.agentType === 'claude-code') {
4516
- resumable.add(conv.id);
4517
- }
4597
+ resumable.add(conv.id); // All agent types are resumable
4518
4598
  }
4519
4599
 
4520
4600
  const staleSessions = queries.getActiveSessions ? queries.getActiveSessions() : [];
@@ -4554,23 +4634,29 @@ function recoverStaleSessions() {
4554
4634
 
4555
4635
  async function resumeInterruptedStreams() {
4556
4636
  try {
4637
+ const RESUME_WINDOW_MS = 600000; // Only resume sessions active within the last 10 minutes
4638
+ const cutoff = Date.now() - RESUME_WINDOW_MS;
4639
+
4557
4640
  // Get conversations marked as streaming in database (isStreaming=1)
4558
4641
  // Fall back to getResumableConversations if isStreaming is not being used
4559
4642
  let toResume = [];
4560
4643
 
4561
4644
  // Primary: Check database isStreaming flag for conversations still marked as active
4562
- // Exclude conversations whose last session completed - they should not be resumed
4645
+ // Exclude conversations whose last session completed or started more than 10 min ago
4563
4646
  const streamingConvs = queries.getConversations().filter(c => {
4564
4647
  if (c.isStreaming !== 1) return false;
4565
4648
  const lastSession = queries.getLatestSession(c.id);
4566
- return !lastSession || lastSession.status !== 'complete';
4649
+ if (!lastSession) return false;
4650
+ if (lastSession.status === 'complete') return false;
4651
+ // Only resume if session started within the last 10 minutes
4652
+ return lastSession.started_at > cutoff;
4567
4653
  });
4568
4654
 
4569
4655
  if (streamingConvs.length > 0) {
4570
4656
  toResume = streamingConvs;
4571
4657
  } else {
4572
- // Fallback: Use session-based resumable conversations
4573
- toResume = queries.getResumableConversations ? queries.getResumableConversations() : [];
4658
+ // Fallback: Use session-based resumable conversations (already filtered by 10 min)
4659
+ toResume = queries.getResumableConversations ? queries.getResumableConversations(RESUME_WINDOW_MS) : [];
4574
4660
  }
4575
4661
 
4576
4662
  if (toResume.length === 0) return;
@@ -4580,87 +4666,13 @@ async function resumeInterruptedStreams() {
4580
4666
  for (let i = 0; i < toResume.length; i++) {
4581
4667
  const conv = toResume[i];
4582
4668
  try {
4583
- // Find previous incomplete sessions to load checkpoint from
4584
4669
  const previousSessions = [...queries.getSessionsByStatus(conv.id, 'active'), ...queries.getSessionsByStatus(conv.id, 'pending')];
4585
4670
  const previousSessionId = previousSessions.length > 0 ? previousSessions[0].id : null;
4586
-
4587
- // Load checkpoint from previous session
4588
- const checkpoint = previousSessionId ? checkpointManager.loadCheckpoint(previousSessionId) : null;
4589
-
4590
- for (const s of previousSessions) {
4591
- queries.updateSession(s.id, { status: 'interrupted', error: 'Server restarted, resuming', completed_at: Date.now() });
4592
- }
4593
-
4594
- const lastMsg = queries.getLastUserMessage(conv.id);
4595
- const prompt = lastMsg?.content || 'continue';
4596
- const promptText = typeof prompt === 'string' ? prompt : JSON.stringify(prompt);
4597
-
4598
- const session = queries.createSession(conv.id);
4599
- queries.createEvent('session.created', {
4600
- sessionId: session.id,
4601
- resumeReason: 'server_restart',
4602
- claudeSessionId: conv.claudeSessionId,
4603
- checkpointFrom: previousSessionId
4604
- }, conv.id, session.id);
4605
-
4606
- activeExecutions.set(conv.id, {
4607
- pid: null,
4608
- startTime: Date.now(),
4609
- sessionId: session.id,
4610
- lastActivity: Date.now()
4611
- });
4612
-
4613
- broadcastSync({
4614
- type: 'streaming_start',
4615
- sessionId: session.id,
4616
- conversationId: conv.id,
4617
- agentId: conv.agentType,
4618
- resumed: true,
4619
- checkpointAvailable: !!checkpoint,
4620
- timestamp: Date.now()
4621
- });
4622
-
4623
- // Store checkpoint to inject when client subscribes (not now, since no clients connected yet)
4624
- if (checkpoint) {
4625
- checkpointManager.storeCheckpointForDelay(conv.id, checkpoint);
4626
- checkpointManager.markSessionResumed(previousSessionId);
4627
- console.log(`[RESUME] Checkpoint stored for ${conv.id}, will inject on next client subscribe`);
4628
- }
4629
-
4630
- const messageId = lastMsg?.id || null;
4631
- console.log(`[RESUME] Resuming conv ${conv.id} (claude session: ${conv.claudeSessionId}) with checkpoint=${!!checkpoint}`);
4632
-
4633
- try {
4634
- await processMessageWithStreaming(conv.id, messageId, session.id, promptText, conv.agentType, conv.model, conv.subAgent);
4635
- } catch (err) {
4636
- console.error(`[RESUME] Error resuming conv ${conv.id}: ${err.message}`);
4637
- queries.setIsStreaming(conv.id, false);
4638
- const activeSessions = queries.getSessionsByStatus(conv.id, 'active');
4639
- const pendingSessions = queries.getSessionsByStatus(conv.id, 'pending');
4640
- for (const s of [...activeSessions, ...pendingSessions]) {
4641
- queries.updateSession(s.id, {
4642
- status: 'error',
4643
- error: 'Resume failed: ' + err.message,
4644
- completed_at: Date.now()
4645
- });
4646
- }
4647
- }
4648
-
4649
- if (i < toResume.length - 1) {
4650
- await new Promise(r => setTimeout(r, 200));
4651
- }
4671
+ await resumeConversation(conv.id, previousSessionId, 'Server restarted, resuming');
4672
+ if (i < toResume.length - 1) await new Promise(r => setTimeout(r, 200));
4652
4673
  } catch (err) {
4653
4674
  console.error(`[RESUME] Failed to resume conv ${conv.id}: ${err.message}`);
4654
4675
  queries.setIsStreaming(conv.id, false);
4655
- const activeSessions = queries.getSessionsByStatus(conv.id, 'active');
4656
- const pendingSessions = queries.getSessionsByStatus(conv.id, 'pending');
4657
- for (const s of [...activeSessions, ...pendingSessions]) {
4658
- queries.updateSession(s.id, {
4659
- status: 'error',
4660
- error: 'Resume failed: ' + err.message,
4661
- completed_at: Date.now()
4662
- });
4663
- }
4664
4676
  }
4665
4677
  }
4666
4678
  } catch (err) {
@@ -4681,14 +4693,29 @@ function isProcessAlive(pid) {
4681
4693
  function markAgentDead(conversationId, entry, reason) {
4682
4694
  if (!activeExecutions.has(conversationId)) return;
4683
4695
  activeExecutions.delete(conversationId);
4696
+
4697
+ const RESUME_WINDOW_MS = 600000; // 10 minutes
4698
+ const sessionAge = entry.startTime ? Date.now() - entry.startTime : Infinity;
4699
+ const shouldRestart = sessionAge < RESUME_WINDOW_MS;
4700
+
4684
4701
  queries.setIsStreaming(conversationId, false);
4685
4702
  if (entry.sessionId) {
4686
4703
  queries.updateSession(entry.sessionId, {
4687
- status: 'error',
4704
+ status: shouldRestart ? 'interrupted' : 'error',
4688
4705
  error: reason,
4689
4706
  completed_at: Date.now()
4690
4707
  });
4691
4708
  }
4709
+
4710
+ if (shouldRestart) {
4711
+ // Session was recent — restart it automatically
4712
+ resumeConversation(conversationId, entry.sessionId, reason).catch(err => {
4713
+ console.error(`[RESUME] Auto-restart failed for conv ${conversationId}: ${err.message}`);
4714
+ queries.setIsStreaming(conversationId, false);
4715
+ });
4716
+ return;
4717
+ }
4718
+
4692
4719
  broadcastSync({
4693
4720
  type: 'streaming_error',
4694
4721
  sessionId: entry.sessionId,
@@ -4700,6 +4727,61 @@ function markAgentDead(conversationId, entry, reason) {
4700
4727
  drainMessageQueue(conversationId);
4701
4728
  }
4702
4729
 
4730
+ // Resume a single conversation after interruption. Used both by markAgentDead and resumeInterruptedStreams.
4731
+ async function resumeConversation(conversationId, previousSessionId, reason) {
4732
+ const conv = queries.getConversation(conversationId);
4733
+ if (!conv) throw new Error('Conversation not found');
4734
+
4735
+ const checkpoint = previousSessionId ? checkpointManager.loadCheckpoint(previousSessionId) : null;
4736
+
4737
+ if (previousSessionId) {
4738
+ // Only mark interrupted if not already done
4739
+ const prev = queries.getSession ? queries.getSession(previousSessionId) : null;
4740
+ if (prev && prev.status !== 'interrupted') {
4741
+ queries.updateSession(previousSessionId, { status: 'interrupted', error: reason || 'Restarting', completed_at: Date.now() });
4742
+ }
4743
+ if (checkpoint) {
4744
+ checkpointManager.markSessionResumed(previousSessionId);
4745
+ }
4746
+ }
4747
+
4748
+ const lastMsg = queries.getLastUserMessage(conversationId);
4749
+ const promptText = typeof lastMsg?.content === 'string' ? lastMsg.content : JSON.stringify(lastMsg?.content || 'continue');
4750
+
4751
+ const session = queries.createSession(conversationId);
4752
+ queries.createEvent('session.created', {
4753
+ sessionId: session.id,
4754
+ resumeReason: 'interrupted',
4755
+ claudeSessionId: conv.claudeSessionId,
4756
+ checkpointFrom: previousSessionId || null
4757
+ }, conversationId, session.id);
4758
+
4759
+ activeExecutions.set(conversationId, {
4760
+ pid: null,
4761
+ startTime: Date.now(),
4762
+ sessionId: session.id,
4763
+ lastActivity: Date.now()
4764
+ });
4765
+
4766
+ broadcastSync({
4767
+ type: 'streaming_start',
4768
+ sessionId: session.id,
4769
+ conversationId,
4770
+ agentId: conv.agentType,
4771
+ resumed: true,
4772
+ checkpointAvailable: !!checkpoint,
4773
+ timestamp: Date.now()
4774
+ });
4775
+
4776
+ if (checkpoint) {
4777
+ checkpointManager.storeCheckpointForDelay(conversationId, checkpoint);
4778
+ console.log(`[RESUME] Checkpoint stored for conv ${conversationId}`);
4779
+ }
4780
+
4781
+ console.log(`[RESUME] Restarting conv ${conversationId} (reason: ${reason})`);
4782
+ await processMessageWithStreaming(conversationId, lastMsg?.id || null, session.id, promptText, conv.agentType, conv.model, conv.subAgent);
4783
+ }
4784
+
4703
4785
  function performAgentHealthCheck() {
4704
4786
  const now = Date.now();
4705
4787
  for (const [conversationId, entry] of activeExecutions) {
@@ -4725,6 +4807,9 @@ function performAgentHealthCheck() {
4725
4807
  }
4726
4808
 
4727
4809
  function onServerReady() {
4810
+ // Clear tool status cache on startup to ensure fresh detection
4811
+ toolManager.clearStatusCache();
4812
+
4728
4813
  console.log(`GMGUI running on http://localhost:${PORT}${BASE_URL}/`);
4729
4814
  console.log(`Agents: ${discoveredAgents.map(a => a.name).join(', ') || 'none'}`);
4730
4815
  console.log(`Hot reload: ${watch ? 'on' : 'off'}`);
@@ -4768,6 +4853,11 @@ function onServerReady() {
4768
4853
  } else if (evt.type === 'tool_install_failed' || evt.type === 'tool_update_failed') {
4769
4854
  queries.updateToolStatus(evt.toolId, { status: 'failed', error_message: evt.data?.error });
4770
4855
  queries.addToolInstallHistory(evt.toolId, evt.type.includes('update') ? 'update' : 'install', 'failed', evt.data?.error);
4856
+ } else if (evt.type === 'tool_status_update') {
4857
+ const d = evt.data || {};
4858
+ if (d.installed) {
4859
+ queries.updateToolStatus(evt.toolId, { status: 'installed', version: d.installedVersion || null, installed_at: Date.now() });
4860
+ }
4771
4861
  }
4772
4862
  }).catch(err => console.error('[TOOLS] Auto-provision error:', err.message));
4773
4863