agentgui 1.0.751 → 1.0.752

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.
@@ -0,0 +1,161 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { fileURLToPath } from 'url';
5
+ import { execSync } from 'child_process';
6
+
7
+ const BINARIES = [
8
+ { cmd: 'claude', id: 'claude-code', name: 'Claude Code', icon: 'C', protocol: 'cli' },
9
+ { cmd: 'opencode', id: 'opencode', name: 'OpenCode', icon: 'O', protocol: 'acp', npxPackage: 'opencode-ai' },
10
+ { cmd: 'gemini', id: 'gemini', name: 'Gemini CLI', icon: 'G', protocol: 'acp', npxPackage: '@google/gemini-cli' },
11
+ { cmd: 'kilo', id: 'kilo', name: 'Kilo Code', icon: 'K', protocol: 'acp', npxPackage: '@kilocode/cli' },
12
+ { cmd: 'goose', id: 'goose', name: 'Goose', icon: 'g', protocol: 'acp' },
13
+ { cmd: 'openhands', id: 'openhands', name: 'OpenHands', icon: 'H', protocol: 'acp' },
14
+ { cmd: 'augment', id: 'augment', name: 'Augment Code', icon: 'A', protocol: 'acp' },
15
+ { cmd: 'cline', id: 'cline', name: 'Cline', icon: 'c', protocol: 'acp' },
16
+ { cmd: 'kimi', id: 'kimi', name: 'Kimi CLI', icon: 'K', protocol: 'acp' },
17
+ { cmd: 'qwen-code', id: 'qwen', name: 'Qwen Code', icon: 'Q', protocol: 'acp' },
18
+ { cmd: 'codex', id: 'codex', name: 'Codex CLI', icon: 'X', protocol: 'acp', npxPackage: '@openai/codex' },
19
+ { cmd: 'mistral-vibe', id: 'mistral', name: 'Mistral Vibe', icon: 'M', protocol: 'acp' },
20
+ { cmd: 'kiro', id: 'kiro', name: 'Kiro CLI', icon: 'k', protocol: 'acp' },
21
+ { cmd: 'fast-agent', id: 'fast-agent', name: 'fast-agent', icon: 'F', protocol: 'acp' },
22
+ ];
23
+
24
+ const CLI_WRAPPERS = [
25
+ { id: 'cli-opencode', name: 'OpenCode', icon: 'O', protocol: 'cli-wrapper', acpId: 'opencode' },
26
+ { id: 'cli-gemini', name: 'Gemini', icon: 'G', protocol: 'cli-wrapper', acpId: 'gemini' },
27
+ { id: 'cli-kilo', name: 'Kilo', icon: 'K', protocol: 'cli-wrapper', acpId: 'kilo' },
28
+ { id: 'cli-codex', name: 'Codex', icon: 'X', protocol: 'cli-wrapper', acpId: 'codex' },
29
+ ];
30
+
31
+ export function findCommand(cmd, rootDir) {
32
+ const isWindows = os.platform() === 'win32';
33
+ const localBin = path.join(rootDir, 'node_modules', '.bin', isWindows ? cmd + '.cmd' : cmd);
34
+ if (fs.existsSync(localBin)) {
35
+ console.log(`[agent-discovery] Found ${cmd} in local node_modules`);
36
+ return localBin;
37
+ }
38
+ try {
39
+ const timeoutMs = 10000;
40
+ if (isWindows) {
41
+ const result = execSync(`where ${cmd}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'], timeout: timeoutMs }).trim();
42
+ if (result) {
43
+ console.log(`[agent-discovery] Found ${cmd} in PATH`);
44
+ return result.split('\n')[0].trim();
45
+ }
46
+ } else {
47
+ const result = execSync(`which ${cmd}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'], timeout: timeoutMs }).trim();
48
+ if (result) {
49
+ console.log(`[agent-discovery] Found ${cmd} in PATH`);
50
+ return result;
51
+ }
52
+ }
53
+ } catch (err) {
54
+ console.log(`[agent-discovery] ${cmd} not found or timed out`);
55
+ return null;
56
+ }
57
+ return null;
58
+ }
59
+
60
+ export async function queryACPServerAgents(baseUrl) {
61
+ const endpoint = baseUrl.endsWith('/') ? baseUrl + 'agents/search' : baseUrl + '/agents/search';
62
+ try {
63
+ const response = await fetch(endpoint, {
64
+ method: 'POST',
65
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
66
+ body: JSON.stringify({}),
67
+ signal: AbortSignal.timeout(5000)
68
+ });
69
+ if (!response.ok) {
70
+ console.error(`Failed to query ACP agents from ${baseUrl}: ${response.status}`);
71
+ return [];
72
+ }
73
+ const data = await response.json();
74
+ if (!data?.agents || !Array.isArray(data.agents)) {
75
+ console.error(`Invalid agents response from ${baseUrl}`);
76
+ return [];
77
+ }
78
+ return data.agents.map(agent => ({
79
+ id: agent.agent_id || agent.id,
80
+ name: agent.metadata?.ref?.name || agent.name || 'Unknown Agent',
81
+ metadata: {
82
+ ref: {
83
+ name: agent.metadata?.ref?.name,
84
+ version: agent.metadata?.ref?.version,
85
+ url: agent.metadata?.ref?.url,
86
+ tags: agent.metadata?.ref?.tags
87
+ },
88
+ description: agent.metadata?.description,
89
+ author: agent.metadata?.author,
90
+ license: agent.metadata?.license
91
+ },
92
+ specs: agent.specs ? {
93
+ capabilities: agent.specs.capabilities,
94
+ input_schema: agent.specs.input_schema || agent.specs.input,
95
+ output_schema: agent.specs.output_schema || agent.specs.output,
96
+ thread_state_schema: agent.specs.thread_state_schema || agent.specs.thread_state,
97
+ config_schema: agent.specs.config_schema || agent.specs.config,
98
+ custom_streaming_update_schema: agent.specs.custom_streaming_update_schema || agent.specs.custom_streaming_update
99
+ } : null,
100
+ custom_data: agent.custom_data,
101
+ icon: agent.metadata?.ref?.name?.charAt(0) || 'A',
102
+ protocol: 'acp',
103
+ path: baseUrl
104
+ }));
105
+ } catch (error) {
106
+ console.error(`ACP agents query failed for ${baseUrl}: ${error.message}`);
107
+ return [];
108
+ }
109
+ }
110
+
111
+ export function discoverAgents(rootDir) {
112
+ const agents = [];
113
+ for (const bin of BINARIES) {
114
+ const result = findCommand(bin.cmd, rootDir);
115
+ if (result) {
116
+ agents.push({ id: bin.id, name: bin.name, icon: bin.icon, path: result, protocol: bin.protocol });
117
+ } else if (bin.npxPackage) {
118
+ agents.push({ id: bin.id, name: bin.name, icon: bin.icon, path: null, protocol: bin.protocol, npxPackage: bin.npxPackage, npxLaunchable: true });
119
+ } else if (bin.id === 'claude-code') {
120
+ agents.push({ id: bin.id, name: bin.name, icon: bin.icon, path: null, protocol: bin.protocol, npxPackage: '@anthropic-ai/claude-code', npxLaunchable: true });
121
+ }
122
+ }
123
+
124
+ console.log('[discoverAgents] Found agents:', agents.map(a => a.id).join(', '));
125
+ for (const wrapper of CLI_WRAPPERS) {
126
+ if (agents.some(a => a.id === wrapper.acpId)) {
127
+ console.log(`[discoverAgents] Adding CLI wrapper for ${wrapper.id}`);
128
+ agents.push(wrapper);
129
+ } else {
130
+ console.log(`[discoverAgents] Skipping CLI wrapper ${wrapper.id} (ACP agent ${wrapper.acpId} not found)`);
131
+ }
132
+ }
133
+ const wrappedAcpIds = new Set(CLI_WRAPPERS.filter(w => agents.some(a => a.id === w.acpId)).map(w => w.acpId));
134
+ const filtered = agents.filter(a => !wrappedAcpIds.has(a.id));
135
+ console.log('[discoverAgents] Final agent count:', filtered.length, 'Agent IDs:', filtered.map(a => a.id).join(', '));
136
+ return filtered;
137
+ }
138
+
139
+ export async function discoverExternalACPServers(discoveredAgents) {
140
+ const externalAgents = [];
141
+ for (const agent of discoveredAgents.filter(a => a.protocol === 'acp' && a.acpPort)) {
142
+ try {
143
+ const agents = await queryACPServerAgents(`http://localhost:${agent.acpPort}`);
144
+ externalAgents.push(...agents);
145
+ } catch (_) {}
146
+ }
147
+ return externalAgents;
148
+ }
149
+
150
+ export async function initializeAgentDiscovery(discoveredAgents, rootDir, logError) {
151
+ try {
152
+ const agents = discoverAgents(rootDir);
153
+ discoveredAgents.length = 0;
154
+ discoveredAgents.push(...agents);
155
+ console.log('[AGENTS] Discovered:', discoveredAgents.map(a => ({ id: a.id, found: !!a.path, protocol: a.protocol })));
156
+ console.log('[AGENTS] Total count:', discoveredAgents.length);
157
+ } catch (err) {
158
+ console.error('[AGENTS] Discovery error:', err.message);
159
+ if (logError) logError('initializeAgentDiscovery', err);
160
+ }
161
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.751",
3
+ "version": "1.0.752",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
package/server.js CHANGED
@@ -17,6 +17,7 @@ import fsbrowse from 'fsbrowse';
17
17
  import { queries } from './database.js';
18
18
  import { runClaudeWithStreaming } from './lib/claude-runner.js';
19
19
  import { initializeDescriptors, getAgentDescriptor } from './lib/agent-descriptors.js';
20
+ import { findCommand, queryACPServerAgents, discoverAgents, discoverExternalACPServers, initializeAgentDiscovery } from './lib/agent-discovery.js';
20
21
  import { WSOptimizer } from './lib/ws-optimizer.js';
21
22
  import { WsRouter } from './lib/ws-protocol.js';
22
23
  import { encode as wsEncode } from './lib/codec.js';
@@ -49,14 +50,20 @@ process.on('unhandledRejection', (reason, promise) => {
49
50
  if (reason instanceof Error) console.error(reason.stack);
50
51
  });
51
52
 
52
- process.on('SIGINT', () => {
53
- console.log('[SIGNAL] SIGINT received - graceful shutdown');
53
+ function gracefulShutdown(signal) {
54
+ console.log(`[SIGNAL] ${signal} received - graceful shutdown`);
54
55
  try { pm2Manager.disconnect(); } catch (_) {}
55
56
  if (jsonlWatcher) try { jsonlWatcher.stop(); } catch (_) {}
57
+ for (const [convId, entry] of activeExecutions) {
58
+ try { if (entry.pid) process.kill(entry.pid, 'SIGTERM'); } catch (_) {}
59
+ }
56
60
  stopACPTools().catch(() => {}).finally(() => {
57
61
  try { wss.close(() => server.close(() => process.exit(0))); } catch (_) { process.exit(0); }
62
+ setTimeout(() => process.exit(1), 5000);
58
63
  });
59
- });
64
+ }
65
+ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
66
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
60
67
  process.on('SIGHUP', () => { console.log('[SIGNAL] SIGHUP received (ignored - uncrashable)'); });
61
68
  process.on('beforeExit', (code) => { console.log('[PROCESS] beforeExit with code:', code); });
62
69
  process.on('exit', (code) => { console.log('[PROCESS] exit with code:', code); });
@@ -444,180 +451,12 @@ expressApp.use(BASE_URL + '/files/:conversationId', (req, res, next) => {
444
451
  router(req, res, next);
445
452
  });
446
453
 
447
- function findCommand(cmd) {
448
- const isWindows = os.platform() === 'win32';
449
- const localBin = path.join(path.dirname(fileURLToPath(import.meta.url)), 'node_modules', '.bin', isWindows ? cmd + '.cmd' : cmd);
450
- if (fs.existsSync(localBin)) {
451
- console.log(`[agent-discovery] Found ${cmd} in local node_modules`);
452
- return localBin;
453
- }
454
- try {
455
- // Increase timeout to 10 seconds to handle slower systems and running agents
456
- const timeoutMs = 10000;
457
- if (isWindows) {
458
- const result = execSync(`where ${cmd}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'], timeout: timeoutMs }).trim();
459
- if (result) {
460
- console.log(`[agent-discovery] Found ${cmd} in PATH`);
461
- return result.split('\n')[0].trim();
462
- }
463
- } else {
464
- const result = execSync(`which ${cmd}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'], timeout: timeoutMs }).trim();
465
- if (result) {
466
- console.log(`[agent-discovery] Found ${cmd} in PATH`);
467
- return result;
468
- }
469
- }
470
- } catch (err) {
471
- console.log(`[agent-discovery] ${cmd} not found or timed out`);
472
- return null;
473
- }
474
- return null;
475
- }
476
-
477
- async function queryACPServerAgents(baseUrl) {
478
- const endpoint = baseUrl.endsWith('/') ? baseUrl + 'agents/search' : baseUrl + '/agents/search';
479
- try {
480
- const response = await fetch(endpoint, {
481
- method: 'POST',
482
- headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
483
- body: JSON.stringify({}),
484
- signal: AbortSignal.timeout(5000)
485
- });
486
- if (!response.ok) {
487
- console.error(`Failed to query ACP agents from ${baseUrl}: ${response.status}`);
488
- return [];
489
- }
490
- const data = await response.json();
491
- if (!data?.agents || !Array.isArray(data.agents)) {
492
- console.error(`Invalid agents response from ${baseUrl}`);
493
- return [];
494
- }
495
- return data.agents.map(agent => ({
496
- id: agent.agent_id || agent.id,
497
- name: agent.metadata?.ref?.name || agent.name || 'Unknown Agent',
498
- metadata: {
499
- ref: {
500
- name: agent.metadata?.ref?.name,
501
- version: agent.metadata?.ref?.version,
502
- url: agent.metadata?.ref?.url,
503
- tags: agent.metadata?.ref?.tags
504
- },
505
- description: agent.metadata?.description,
506
- author: agent.metadata?.author,
507
- license: agent.metadata?.license
508
- },
509
- specs: agent.specs ? {
510
- capabilities: agent.specs.capabilities,
511
- input_schema: agent.specs.input_schema || agent.specs.input,
512
- output_schema: agent.specs.output_schema || agent.specs.output,
513
- thread_state_schema: agent.specs.thread_state_schema || agent.specs.thread_state,
514
- config_schema: agent.specs.config_schema || agent.specs.config,
515
- custom_streaming_update_schema: agent.specs.custom_streaming_update_schema || agent.specs.custom_streaming_update
516
- } : null,
517
- custom_data: agent.custom_data,
518
- icon: agent.metadata?.ref?.name?.charAt(0) || 'A',
519
- protocol: 'acp',
520
- path: baseUrl
521
- }));
522
- } catch (error) {
523
- console.error(`ACP agents query failed for ${baseUrl}: ${error.message}`);
524
- return [];
525
- }
526
- }
527
-
528
- function discoverAgents() {
529
- const agents = [];
530
- const binaries = [
531
- { cmd: 'claude', id: 'claude-code', name: 'Claude Code', icon: 'C', protocol: 'cli' },
532
- { cmd: 'opencode', id: 'opencode', name: 'OpenCode', icon: 'O', protocol: 'acp', npxPackage: 'opencode-ai' },
533
- { cmd: 'gemini', id: 'gemini', name: 'Gemini CLI', icon: 'G', protocol: 'acp', npxPackage: '@google/gemini-cli' },
534
- { cmd: 'kilo', id: 'kilo', name: 'Kilo Code', icon: 'K', protocol: 'acp', npxPackage: '@kilocode/cli' },
535
- { cmd: 'goose', id: 'goose', name: 'Goose', icon: 'g', protocol: 'acp' },
536
- { cmd: 'openhands', id: 'openhands', name: 'OpenHands', icon: 'H', protocol: 'acp' },
537
- { cmd: 'augment', id: 'augment', name: 'Augment Code', icon: 'A', protocol: 'acp' },
538
- { cmd: 'cline', id: 'cline', name: 'Cline', icon: 'c', protocol: 'acp' },
539
- { cmd: 'kimi', id: 'kimi', name: 'Kimi CLI', icon: 'K', protocol: 'acp' },
540
- { cmd: 'qwen-code', id: 'qwen', name: 'Qwen Code', icon: 'Q', protocol: 'acp' },
541
- { cmd: 'codex', id: 'codex', name: 'Codex CLI', icon: 'X', protocol: 'acp', npxPackage: '@openai/codex' },
542
- { cmd: 'mistral-vibe', id: 'mistral', name: 'Mistral Vibe', icon: 'M', protocol: 'acp' },
543
- { cmd: 'kiro', id: 'kiro', name: 'Kiro CLI', icon: 'k', protocol: 'acp' },
544
- { cmd: 'fast-agent', id: 'fast-agent', name: 'fast-agent', icon: 'F', protocol: 'acp' },
545
- ];
546
- for (const bin of binaries) {
547
- const result = findCommand(bin.cmd);
548
- if (result) {
549
- agents.push({ id: bin.id, name: bin.name, icon: bin.icon, path: result, protocol: bin.protocol });
550
- } else if (bin.npxPackage) {
551
- // For npx-launchable packages (including claude-code as fallback)
552
- agents.push({ id: bin.id, name: bin.name, icon: bin.icon, path: null, protocol: bin.protocol, npxPackage: bin.npxPackage, npxLaunchable: true });
553
- } else if (bin.id === 'claude-code') {
554
- // Ensure Claude Code is always available as an npx-launchable agent
555
- agents.push({ id: bin.id, name: bin.name, icon: bin.icon, path: null, protocol: bin.protocol, npxPackage: '@anthropic-ai/claude-code', npxLaunchable: true });
556
- }
557
- }
558
-
559
- // Add CLI tool wrappers for ACP agents (these map CLI commands to plugin sub-agents)
560
- // These allow users to select "OpenCode", "Gemini", etc. and then pick a model/variant
561
- const cliWrappers = [
562
- { id: 'cli-opencode', name: 'OpenCode', icon: 'O', protocol: 'cli-wrapper', acpId: 'opencode' },
563
- { id: 'cli-gemini', name: 'Gemini', icon: 'G', protocol: 'cli-wrapper', acpId: 'gemini' },
564
- { id: 'cli-kilo', name: 'Kilo', icon: 'K', protocol: 'cli-wrapper', acpId: 'kilo' },
565
- { id: 'cli-codex', name: 'Codex', icon: 'X', protocol: 'cli-wrapper', acpId: 'codex' },
566
- ];
567
-
568
- // Only add CLI wrappers for agents that are already discovered
569
- console.log('[discoverAgents] Found agents:', agents.map(a => a.id).join(', '));
570
- for (const wrapper of cliWrappers) {
571
- if (agents.some(a => a.id === wrapper.acpId)) {
572
- console.log(`[discoverAgents] Adding CLI wrapper for ${wrapper.id}`);
573
- agents.push(wrapper);
574
- console.log(`[discoverAgents] After push, agents.length = ${agents.length}, includes ${wrapper.id}? ${agents.some(a => a.id === wrapper.id)}`);
575
- } else {
576
- console.log(`[discoverAgents] Skipping CLI wrapper ${wrapper.id} (ACP agent ${wrapper.acpId} not found)`);
577
- }
578
- }
579
- // Remove raw ACP agents that have a cli-wrapper (avoid duplicates in UI)
580
- const wrappedAcpIds = new Set(cliWrappers.filter(w => agents.some(a => a.id === w.acpId)).map(w => w.acpId));
581
- const filtered = agents.filter(a => !wrappedAcpIds.has(a.id));
582
- console.log('[discoverAgents] Final agent count:', filtered.length, 'Agent IDs:', filtered.map(a => a.id).join(', '));
583
-
584
- return filtered;
585
- }
586
-
587
- // Function to discover agents from external ACP servers
588
- async function discoverExternalACPServers() {
589
- const externalAgents = [];
590
- for (const agent of discoveredAgents.filter(a => a.protocol === 'acp' && a.acpPort)) {
591
- try {
592
- const agents = await queryACPServerAgents(`http://localhost:${agent.acpPort}`);
593
- externalAgents.push(...agents);
594
- } catch (_) {}
595
- }
596
- return externalAgents;
597
- }
598
-
599
454
  let discoveredAgents = [];
600
455
  initializeDescriptors(discoveredAgents);
601
456
 
602
- // Agent discovery happens asynchronously in background to not block startup
603
- async function initializeAgentDiscovery() {
604
- try {
605
- const agents = discoverAgents();
606
- // Mutate the existing array instead of reassigning to preserve closure references in handlers
607
- discoveredAgents.length = 0;
608
- discoveredAgents.push(...agents);
609
- initializeDescriptors(discoveredAgents);
610
- console.log('[AGENTS] Discovered:', discoveredAgents.map(a => ({ id: a.id, found: !!a.path, protocol: a.protocol })));
611
- console.log('[AGENTS] Total count:', discoveredAgents.length);
612
- } catch (err) {
613
- console.error('[AGENTS] Discovery error:', err.message);
614
- logError('initializeAgentDiscovery', err);
615
- }
616
- }
617
-
618
- // Start immediately but don't wait for it
619
457
  const startTime = Date.now();
620
- initializeAgentDiscovery().then(() => {
458
+ initializeAgentDiscovery(discoveredAgents, rootDir, logError).then(() => {
459
+ initializeDescriptors(discoveredAgents);
621
460
  console.log('[INIT] initializeAgentDiscovery completed in', Date.now() - startTime, 'ms');
622
461
  }).catch(() => {});
623
462
 
@@ -666,7 +505,7 @@ function getGeminiOAuthCreds() {
666
505
  }
667
506
  const oauthRelPath = path.join('node_modules', '@google', 'gemini-cli-core', 'dist', 'src', 'code_assist', 'oauth2.js');
668
507
  try {
669
- const geminiPath = findCommand('gemini');
508
+ const geminiPath = findCommand('gemini', rootDir);
670
509
  if (geminiPath) {
671
510
  const realPath = fs.realpathSync(geminiPath);
672
511
  const pkgRoot = path.resolve(path.dirname(realPath), '..');
@@ -1364,6 +1203,9 @@ function sendJSON(req, res, statusCode, data) {
1364
1203
  compressAndSend(req, res, statusCode, 'application/json', JSON.stringify(data));
1365
1204
  }
1366
1205
 
1206
+ const _rateLimitMap = new LRUCache({ max: 1000, ttl: 60000 });
1207
+ const RATE_LIMIT_MAX = parseInt(process.env.RATE_LIMIT_MAX || '300', 10);
1208
+
1367
1209
  const server = http.createServer(async (req, res) => {
1368
1210
  res.setHeader('Access-Control-Allow-Origin', '*');
1369
1211
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
@@ -1371,6 +1213,17 @@ const server = http.createServer(async (req, res) => {
1371
1213
  if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; }
1372
1214
  if (req.headers.upgrade && req.headers.upgrade.toLowerCase() === 'websocket') return;
1373
1215
 
1216
+ const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket.remoteAddress;
1217
+ const hits = (_rateLimitMap.get(clientIp) || 0) + 1;
1218
+ _rateLimitMap.set(clientIp, hits);
1219
+ res.setHeader('X-RateLimit-Limit', RATE_LIMIT_MAX);
1220
+ res.setHeader('X-RateLimit-Remaining', Math.max(0, RATE_LIMIT_MAX - hits));
1221
+ if (hits > RATE_LIMIT_MAX) {
1222
+ res.writeHead(429, { 'Retry-After': '60' });
1223
+ res.end('Too Many Requests');
1224
+ return;
1225
+ }
1226
+
1374
1227
  const _pwd = process.env.PASSWORD;
1375
1228
  if (_pwd) {
1376
1229
  const _auth = req.headers['authorization'] || '';
@@ -1437,6 +1290,20 @@ const server = http.createServer(async (req, res) => {
1437
1290
  return;
1438
1291
  }
1439
1292
 
1293
+ if (pathOnly === '/api/health' && req.method === 'GET') {
1294
+ sendJSON(req, res, 200, {
1295
+ status: 'ok',
1296
+ version: PKG_VERSION,
1297
+ uptime: process.uptime(),
1298
+ agents: discoveredAgents.length,
1299
+ activeExecutions: activeExecutions.size,
1300
+ wsClients: wss.clients.size,
1301
+ memory: process.memoryUsage(),
1302
+ acp: getACPStatus()
1303
+ });
1304
+ return;
1305
+ }
1306
+
1440
1307
  if (pathOnly === '/api/conversations' && req.method === 'GET') {
1441
1308
  const conversations = queries.getConversationsList();
1442
1309
  // Filter out stale streaming state using a single bulk query instead of N+1 per-conversation queries
@@ -2426,7 +2293,7 @@ const server = http.createServer(async (req, res) => {
2426
2293
  const localResult = queries.searchAgents(discoveredAgents, body);
2427
2294
 
2428
2295
  // Get external agents from ACP servers
2429
- const externalAgents = await discoverExternalACPServers();
2296
+ const externalAgents = await discoverExternalACPServers(discoveredAgents);
2430
2297
  const externalResult = queries.searchAgents(externalAgents, body);
2431
2298
 
2432
2299
  // Combine results
package/static/app.js CHANGED
@@ -749,4 +749,82 @@ function downloadScreenshot() {
749
749
  console.log('Download screenshot not yet implemented');
750
750
  }
751
751
 
752
+ function showShortcutsOverlay() {
753
+ if (document.querySelector('.shortcuts-overlay')) return;
754
+ const overlay = document.createElement('div');
755
+ overlay.className = 'shortcuts-overlay';
756
+ overlay.innerHTML = `<div class="shortcuts-panel">
757
+ <h3>Keyboard Shortcuts</h3>
758
+ <table>
759
+ <tr><td><kbd>Ctrl</kbd>+<kbd>N</kbd></td><td>New conversation</td></tr>
760
+ <tr><td><kbd>Ctrl</kbd>+<kbd>B</kbd></td><td>Toggle sidebar</td></tr>
761
+ <tr><td><kbd>Ctrl</kbd>+<kbd>Enter</kbd></td><td>Send message</td></tr>
762
+ <tr><td><kbd>Escape</kbd></td><td>Cancel stream / blur input</td></tr>
763
+ <tr><td><kbd>?</kbd></td><td>Show this help</td></tr>
764
+ </table>
765
+ <p style="margin:1rem 0 0;font-size:0.75rem;color:#9ca3af;">Press Escape to close</p>
766
+ </div>`;
767
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
768
+ document.body.appendChild(overlay);
769
+ }
770
+
771
+ document.addEventListener('keydown', (e) => {
772
+ if (e.key === '?' && !e.ctrlKey && !e.metaKey) {
773
+ const tag = document.activeElement?.tagName;
774
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
775
+ e.preventDefault();
776
+ showShortcutsOverlay();
777
+ }
778
+ if (e.key === 'Escape' && document.querySelector('.shortcuts-overlay')) {
779
+ document.querySelector('.shortcuts-overlay').remove();
780
+ }
781
+ });
782
+
783
+ (function initSidebarSearch() {
784
+ const input = document.getElementById('sidebarSearchInput');
785
+ if (!input) return;
786
+ let debounceTimer;
787
+ input.addEventListener('input', () => {
788
+ clearTimeout(debounceTimer);
789
+ debounceTimer = setTimeout(() => {
790
+ const query = input.value.trim();
791
+ const list = document.querySelector('[data-conversation-list]');
792
+ if (!list) return;
793
+ const items = list.querySelectorAll('li[data-conv-id]');
794
+ if (!query) {
795
+ items.forEach(li => li.style.display = '');
796
+ return;
797
+ }
798
+ const lower = query.toLowerCase();
799
+ items.forEach(li => {
800
+ const text = li.textContent.toLowerCase();
801
+ li.style.display = text.includes(lower) ? '' : 'none';
802
+ });
803
+ }, 150);
804
+ });
805
+ })();
806
+
807
+ (function initErrorBoundary() {
808
+ let toastEl = null;
809
+ let hideTimer = null;
810
+ function showErrorToast(title, detail) {
811
+ if (toastEl) toastEl.remove();
812
+ toastEl = document.createElement('div');
813
+ toastEl.className = 'error-toast';
814
+ toastEl.innerHTML = `<div class="error-toast-title">${title}</div><div class="error-toast-detail">${detail}</div>`;
815
+ toastEl.addEventListener('click', () => { toastEl.remove(); toastEl = null; });
816
+ document.body.appendChild(toastEl);
817
+ clearTimeout(hideTimer);
818
+ hideTimer = setTimeout(() => { if (toastEl) { toastEl.remove(); toastEl = null; } }, 8000);
819
+ }
820
+ window.addEventListener('error', (e) => {
821
+ if (e.filename) showErrorToast('JavaScript Error', `${e.message} at ${e.filename.split('/').pop()}:${e.lineno}`);
822
+ });
823
+ window.addEventListener('unhandledrejection', (e) => {
824
+ const msg = e.reason?.message || String(e.reason || 'Unknown');
825
+ showErrorToast('Unhandled Promise Rejection', msg);
826
+ });
827
+ window.showErrorToast = showErrorToast;
828
+ })();
829
+
752
830
  window.addEventListener('load', initializeApp);
@@ -3054,4 +3054,108 @@
3054
3054
  html.dark .html-rendered-container {
3055
3055
  border-color: #262626;
3056
3056
  }
3057
+
3058
+ .sidebar-search-bar {
3059
+ padding: 0.25rem 0.75rem 0.5rem;
3060
+ }
3061
+ .sidebar-search-input {
3062
+ width: 100%;
3063
+ padding: 0.375rem 0.5rem;
3064
+ border: 1px solid #d1d5db;
3065
+ border-radius: 0.375rem;
3066
+ background: #f9fafb;
3067
+ color: #111827;
3068
+ font-size: 0.8125rem;
3069
+ outline: none;
3070
+ box-sizing: border-box;
3071
+ }
3072
+ .sidebar-search-input:focus {
3073
+ border-color: #3b82f6;
3074
+ box-shadow: 0 0 0 2px rgba(59,130,246,0.15);
3075
+ }
3076
+ html.dark .sidebar-search-input {
3077
+ background: #1f2937;
3078
+ border-color: #374151;
3079
+ color: #e5e7eb;
3080
+ }
3081
+
3082
+ .shortcuts-overlay {
3083
+ position: fixed;
3084
+ inset: 0;
3085
+ background: rgba(0,0,0,0.5);
3086
+ display: flex;
3087
+ align-items: center;
3088
+ justify-content: center;
3089
+ z-index: 9999;
3090
+ }
3091
+ .shortcuts-panel {
3092
+ background: white;
3093
+ border-radius: 0.75rem;
3094
+ padding: 1.5rem;
3095
+ max-width: 400px;
3096
+ width: 90%;
3097
+ box-shadow: 0 25px 50px rgba(0,0,0,0.25);
3098
+ }
3099
+ html.dark .shortcuts-panel {
3100
+ background: #1f2937;
3101
+ color: #e5e7eb;
3102
+ }
3103
+ .shortcuts-panel h3 {
3104
+ margin: 0 0 1rem;
3105
+ font-size: 1.125rem;
3106
+ }
3107
+ .shortcuts-panel table {
3108
+ width: 100%;
3109
+ border-collapse: collapse;
3110
+ }
3111
+ .shortcuts-panel td {
3112
+ padding: 0.375rem 0;
3113
+ font-size: 0.875rem;
3114
+ }
3115
+ .shortcuts-panel td:first-child {
3116
+ font-weight: 600;
3117
+ white-space: nowrap;
3118
+ padding-right: 1rem;
3119
+ }
3120
+ .shortcuts-panel kbd {
3121
+ background: #f3f4f6;
3122
+ border: 1px solid #d1d5db;
3123
+ border-radius: 0.25rem;
3124
+ padding: 0.125rem 0.375rem;
3125
+ font-size: 0.75rem;
3126
+ font-family: inherit;
3127
+ }
3128
+ html.dark .shortcuts-panel kbd {
3129
+ background: #374151;
3130
+ border-color: #4b5563;
3131
+ }
3132
+
3133
+ .error-toast {
3134
+ position: fixed;
3135
+ bottom: 1rem;
3136
+ right: 1rem;
3137
+ background: #dc2626;
3138
+ color: white;
3139
+ padding: 0.75rem 1rem;
3140
+ border-radius: 0.5rem;
3141
+ font-size: 0.875rem;
3142
+ max-width: 400px;
3143
+ z-index: 9998;
3144
+ box-shadow: 0 10px 25px rgba(0,0,0,0.2);
3145
+ cursor: pointer;
3146
+ animation: slideInRight 0.2s ease-out;
3147
+ }
3148
+ .error-toast-title {
3149
+ font-weight: 600;
3150
+ margin-bottom: 0.25rem;
3151
+ }
3152
+ .error-toast-detail {
3153
+ opacity: 0.85;
3154
+ font-size: 0.8125rem;
3155
+ word-break: break-word;
3156
+ }
3157
+ @keyframes slideInRight {
3158
+ from { transform: translateX(100%); opacity: 0; }
3159
+ to { transform: translateX(0); opacity: 1; }
3160
+ }
3057
3161
 
package/static/index.html CHANGED
@@ -21,7 +21,7 @@
21
21
  })();
22
22
  </script>
23
23
 
24
- <link rel="stylesheet" href="/gm/css/main.css">
24
+ <link rel="stylesheet" href="/gm/css/main.css">
25
25
  <link rel="stylesheet" href="/gm/css/tools-popup.css">
26
26
  </head>
27
27
  <body>
@@ -39,6 +39,9 @@
39
39
  <button id="newConversationBtn" class="sidebar-new-btn" data-new-conversation title="Start new conversation">+ New</button>
40
40
  </div>
41
41
  </div>
42
+ <div class="sidebar-search-bar" id="sidebarSearchBar">
43
+ <input type="text" class="sidebar-search-input" id="sidebarSearchInput" placeholder="Search conversations..." autocomplete="off" spellcheck="false">
44
+ </div>
42
45
  <div class="clone-input-bar" id="cloneInputBar" style="display:none;">
43
46
  <input type="text" class="clone-input" id="cloneRepoInput" placeholder="org/repo" autocomplete="off" spellcheck="false">
44
47
  <button class="clone-go-btn" id="cloneGoBtn" title="Clone">Go</button>