agentgui 1.0.226 → 1.0.228

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.
@@ -347,18 +347,28 @@ class AgentRunner {
347
347
 
348
348
  if (message.id === promptId && message.result && message.result.stopReason) {
349
349
  completed = true;
350
+ draining = true;
350
351
  clearTimeout(timeoutHandle);
351
- proc.kill();
352
- resolve({ outputs, sessionId });
352
+ // Wait a short time for any remaining events to be flushed before killing
353
+ setTimeout(() => {
354
+ draining = false;
355
+ try { proc.kill(); } catch (e) {}
356
+ resolve({ outputs, sessionId });
357
+ }, 1000);
353
358
  return;
354
359
  }
355
360
 
356
361
  if (message.id === promptId && message.error) {
357
- originalHandler(message);
358
362
  completed = true;
363
+ draining = true;
359
364
  clearTimeout(timeoutHandle);
360
- proc.kill();
361
- reject(new Error(message.error.message || 'ACP prompt error'));
365
+ // Process the error message first, then delay for remaining events
366
+ originalHandler(message);
367
+ setTimeout(() => {
368
+ draining = false;
369
+ try { proc.kill(); } catch (e) {}
370
+ reject(new Error(message.error.message || 'ACP prompt error'));
371
+ }, 1000);
362
372
  return;
363
373
  }
364
374
 
@@ -367,8 +377,11 @@ class AgentRunner {
367
377
 
368
378
  buffer = '';
369
379
  proc.stdout.removeAllListeners('data');
380
+ let draining = false;
370
381
  proc.stdout.on('data', (chunk) => {
371
- if (timedOut || completed) return;
382
+ if (timedOut) return;
383
+ // Continue processing during drain period after stopReason/error
384
+ if (completed && !draining) return;
372
385
 
373
386
  buffer += chunk.toString();
374
387
  const lines = buffer.split('\n');
@@ -397,6 +410,19 @@ class AgentRunner {
397
410
  clearTimeout(timeoutHandle);
398
411
  if (timedOut || completed) return;
399
412
 
413
+ // Flush any remaining buffer content
414
+ if (buffer.trim()) {
415
+ try {
416
+ const message = JSON.parse(buffer.trim());
417
+ if (message.id === 1 && message.result) {
418
+ initialized = true;
419
+ }
420
+ enhancedHandler(message);
421
+ } catch (e) {
422
+ // Buffer might be incomplete, ignore parse errors on close
423
+ }
424
+ }
425
+
400
426
  if (code === 0 || outputs.length > 0) {
401
427
  resolve({ outputs, sessionId });
402
428
  } else {
@@ -889,6 +915,28 @@ registry.register({
889
915
  protocolHandler: acpProtocolHandler
890
916
  });
891
917
 
918
+ /**
919
+ * Kilo CLI Agent (OpenCode fork)
920
+ * Built on OpenCode, supports ACP protocol
921
+ * Uses 'kilo' command - installed via npm install -g @kilocode/cli
922
+ */
923
+ registry.register({
924
+ id: 'kilo',
925
+ name: 'Kilo CLI',
926
+ command: 'kilo',
927
+ protocol: 'acp',
928
+ supportsStdin: false,
929
+ supportedFeatures: ['streaming', 'resume', 'acp-protocol', 'models'],
930
+
931
+ buildArgs(prompt, config) {
932
+ return ['acp'];
933
+ },
934
+
935
+ protocolHandler(message, context) {
936
+ return acpProtocolHandler(message, context);
937
+ }
938
+ });
939
+
892
940
  /**
893
941
  * Main export function - runs any registered agent
894
942
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.226",
3
+ "version": "1.0.228",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -24,6 +24,68 @@ async function ensurePocketTtsSetup(onProgress) {
24
24
  return serverTTS.ensureInstalled(onProgress);
25
25
  }
26
26
 
27
+ // Model download manager
28
+ const modelDownloadState = {
29
+ downloading: false,
30
+ progress: null,
31
+ error: null,
32
+ complete: false
33
+ };
34
+
35
+ async function ensureModelsDownloaded() {
36
+ const { createRequire: cr } = await import('module');
37
+ const r = cr(import.meta.url);
38
+ const { checkAllFilesExist, downloadModels } = r('sttttsmodels');
39
+
40
+ if (checkAllFilesExist()) {
41
+ modelDownloadState.complete = true;
42
+ return true;
43
+ }
44
+
45
+ if (modelDownloadState.downloading) {
46
+ // Wait for current download
47
+ while (modelDownloadState.downloading) {
48
+ await new Promise(r => setTimeout(r, 100));
49
+ }
50
+ return modelDownloadState.complete;
51
+ }
52
+
53
+ modelDownloadState.downloading = true;
54
+ modelDownloadState.error = null;
55
+
56
+ try {
57
+ await downloadModels((progress) => {
58
+ modelDownloadState.progress = progress;
59
+ // Broadcast progress to all connected clients
60
+ broadcastSync({
61
+ type: 'model_download_progress',
62
+ progress: {
63
+ started: progress.started,
64
+ done: progress.done,
65
+ error: progress.error,
66
+ downloading: progress.downloading,
67
+ type: progress.type,
68
+ completedFiles: progress.completedFiles,
69
+ totalFiles: progress.totalFiles,
70
+ totalDownloaded: progress.totalDownloaded,
71
+ totalBytes: progress.totalBytes
72
+ }
73
+ });
74
+ });
75
+ modelDownloadState.complete = true;
76
+ return true;
77
+ } catch (err) {
78
+ modelDownloadState.error = err.message;
79
+ broadcastSync({
80
+ type: 'model_download_progress',
81
+ progress: { error: err.message, done: true }
82
+ });
83
+ return false;
84
+ } finally {
85
+ modelDownloadState.downloading = false;
86
+ }
87
+ }
88
+
27
89
  function eagerTTS(text, conversationId, sessionId) {
28
90
  getSpeech().then(speech => {
29
91
  const status = speech.getStatus();
@@ -204,39 +266,87 @@ const discoveredAgents = discoverAgents();
204
266
 
205
267
  const modelCache = new Map();
206
268
 
269
+ const AGENT_MODEL_COMMANDS = {
270
+ 'opencode': 'opencode models',
271
+ 'kilo': 'kilo models',
272
+ };
273
+
274
+ const AGENT_DEFAULT_MODELS = {
275
+ 'claude-code': [
276
+ { id: '', label: 'Default' },
277
+ { id: 'sonnet', label: 'Sonnet' },
278
+ { id: 'opus', label: 'Opus' },
279
+ { id: 'haiku', label: 'Haiku' },
280
+ { id: 'claude-sonnet-4-5-20250929', label: 'Sonnet 4.5' },
281
+ { id: 'claude-opus-4-6', label: 'Opus 4.6' },
282
+ { id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5' }
283
+ ],
284
+ 'gemini': [
285
+ { id: '', label: 'Default' },
286
+ { id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
287
+ { id: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
288
+ { id: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' }
289
+ ],
290
+ 'goose': [
291
+ { id: '', label: 'Default' },
292
+ { id: 'claude-sonnet-4-5', label: 'Sonnet 4.5' },
293
+ { id: 'claude-opus-4-5', label: 'Opus 4.5' }
294
+ ],
295
+ 'codex': [
296
+ { id: '', label: 'Default' },
297
+ { id: 'o4-mini', label: 'o4-mini' },
298
+ { id: 'o3', label: 'o3' },
299
+ { id: 'o3-mini', label: 'o3-mini' }
300
+ ]
301
+ };
302
+
207
303
  async function getModelsForAgent(agentId) {
208
- if (agentId === 'claude-code') {
209
- return [
210
- { id: '', label: 'Default' },
211
- { id: 'sonnet', label: 'Sonnet' },
212
- { id: 'opus', label: 'Opus' },
213
- { id: 'haiku', label: 'Haiku' },
214
- { id: 'claude-sonnet-4-5-20250929', label: 'Sonnet 4.5' },
215
- { id: 'claude-opus-4-6', label: 'Opus 4.6' },
216
- { id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5' }
217
- ];
304
+ const cached = modelCache.get(agentId);
305
+ if (cached && Date.now() - cached.timestamp < 300000) {
306
+ return cached.models;
307
+ }
308
+
309
+ if (AGENT_DEFAULT_MODELS[agentId]) {
310
+ const models = AGENT_DEFAULT_MODELS[agentId];
311
+ modelCache.set(agentId, { models, timestamp: Date.now() });
312
+ return models;
218
313
  }
219
- if (agentId === 'opencode') {
314
+
315
+ if (AGENT_MODEL_COMMANDS[agentId]) {
220
316
  try {
221
- const result = execSync('opencode models 2>/dev/null', { encoding: 'utf-8', timeout: 10000 });
317
+ const result = execSync(AGENT_MODEL_COMMANDS[agentId], { encoding: 'utf-8', timeout: 15000 });
222
318
  const lines = result.split('\n').map(l => l.trim()).filter(Boolean);
223
319
  const models = [{ id: '', label: 'Default' }];
224
320
  for (const line of lines) {
225
321
  models.push({ id: line, label: line });
226
322
  }
323
+ modelCache.set(agentId, { models, timestamp: Date.now() });
227
324
  return models;
228
325
  } catch (_) {
229
326
  return [{ id: '', label: 'Default' }];
230
327
  }
231
328
  }
232
- if (agentId === 'gemini') {
233
- return [
234
- { id: '', label: 'Default' },
235
- { id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
236
- { id: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
237
- { id: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' }
238
- ];
329
+
330
+ const { getRegisteredAgents } = await import('./lib/claude-runner.js');
331
+ const agents = getRegisteredAgents();
332
+ const agent = agents.find(a => a.id === agentId);
333
+
334
+ if (agent && agent.command) {
335
+ const modelCmd = `${agent.command} models`;
336
+ try {
337
+ const result = execSync(modelCmd, { encoding: 'utf-8', timeout: 15000 });
338
+ const lines = result.split('\n').map(l => l.trim()).filter(Boolean);
339
+ if (lines.length > 0) {
340
+ const models = [{ id: '', label: 'Default' }];
341
+ for (const line of lines) {
342
+ models.push({ id: line, label: line });
343
+ }
344
+ modelCache.set(agentId, { models, timestamp: Date.now() });
345
+ return models;
346
+ }
347
+ } catch (_) {}
239
348
  }
349
+
240
350
  return [];
241
351
  }
242
352
 
@@ -754,7 +864,14 @@ const server = http.createServer(async (req, res) => {
754
864
  }
755
865
 
756
866
  if (pathOnly === '/api/conversations' && req.method === 'GET') {
757
- sendJSON(req, res, 200, { conversations: queries.getConversationsList() });
867
+ const conversations = queries.getConversationsList();
868
+ // Filter out stale streaming state for conversations not in activeExecutions
869
+ for (const conv of conversations) {
870
+ if (conv.isStreaming && !activeExecutions.has(conv.id)) {
871
+ conv.isStreaming = 0;
872
+ }
873
+ }
874
+ sendJSON(req, res, 200, { conversations });
758
875
  return;
759
876
  }
760
877
 
@@ -1553,11 +1670,18 @@ const server = http.createServer(async (req, res) => {
1553
1670
  pythonDetected: pyInfo.found,
1554
1671
  pythonVersion: pyInfo.version || null,
1555
1672
  setupMessage: baseStatus.ttsReady ? 'pocket-tts ready' : 'Will setup on first TTS request',
1673
+ modelsDownloading: modelDownloadState.downloading,
1674
+ modelsComplete: modelDownloadState.complete,
1675
+ modelsError: modelDownloadState.error,
1676
+ modelsProgress: modelDownloadState.progress,
1556
1677
  });
1557
1678
  } catch (err) {
1558
1679
  sendJSON(req, res, 200, {
1559
1680
  sttReady: false, ttsReady: false, sttLoading: false, ttsLoading: false,
1560
1681
  setupMessage: 'Will setup on first TTS request',
1682
+ modelsDownloading: modelDownloadState.downloading,
1683
+ modelsComplete: modelDownloadState.complete,
1684
+ modelsError: modelDownloadState.error,
1561
1685
  });
1562
1686
  }
1563
1687
  return;
@@ -2484,14 +2608,9 @@ function performAgentHealthCheck() {
2484
2608
  markAgentDead(conversationId, entry, 'Agent process died unexpectedly');
2485
2609
  } else if (now - entry.lastActivity > STUCK_AGENT_THRESHOLD_MS) {
2486
2610
  debugLog(`[HEALTH] Agent PID ${entry.pid} for conv ${conversationId} has no activity for ${Math.round((now - entry.lastActivity) / 1000)}s`);
2487
- broadcastSync({
2488
- type: 'streaming_error',
2489
- sessionId: entry.sessionId,
2490
- conversationId,
2491
- error: 'Agent may be stuck (no activity for 10 minutes)',
2492
- recoverable: true,
2493
- timestamp: now
2494
- });
2611
+ // Kill stuck agent and clear streaming state
2612
+ try { process.kill(entry.pid, 'SIGTERM'); } catch (e) {}
2613
+ markAgentDead(conversationId, entry, 'Agent was stuck (no activity for 10 minutes)');
2495
2614
  }
2496
2615
  } else {
2497
2616
  if (now - entry.startTime > NO_PID_GRACE_PERIOD_MS) {
@@ -2519,6 +2638,16 @@ function onServerReady() {
2519
2638
  // Resume interrupted streams after recovery
2520
2639
  resumeInterruptedStreams().catch(err => console.error('[RESUME] Startup error:', err.message));
2521
2640
 
2641
+ // Start model downloads in background after server is ready
2642
+ setTimeout(() => {
2643
+ ensureModelsDownloaded().then(ok => {
2644
+ if (ok) console.log('[MODELS] Speech models ready');
2645
+ else console.log('[MODELS] Speech model download failed');
2646
+ }).catch(err => {
2647
+ console.error('[MODELS] Download error:', err.message);
2648
+ });
2649
+ }, 2000);
2650
+
2522
2651
  getSpeech().then(s => s.preloadTTS()).catch(e => debugLog('[TTS] Preload failed: ' + e.message));
2523
2652
 
2524
2653
  performAutoImport();
package/static/index.html CHANGED
@@ -2271,6 +2271,7 @@
2271
2271
  .connection-dot.unknown { background: #6b7280; }
2272
2272
  .connection-dot.disconnected { background: #ef4444; animation: pulse 1.5s ease-in-out infinite; }
2273
2273
  .connection-dot.reconnecting { background: #f59e0b; animation: pulse 1s ease-in-out infinite; }
2274
+ .connection-dot.downloading { background: #3b82f6; animation: pulse 1s ease-in-out infinite; }
2274
2275
 
2275
2276
  .connection-tooltip {
2276
2277
  position: absolute;
@@ -432,6 +432,9 @@ class AgentGUIClient {
432
432
  case 'rate_limit_clear':
433
433
  this.handleRateLimitClear(data);
434
434
  break;
435
+ case 'model_download_progress':
436
+ this._handleModelDownloadProgress(data.progress);
437
+ break;
435
438
  default:
436
439
  break;
437
440
  }
@@ -813,6 +816,7 @@ class AgentGUIClient {
813
816
  }
814
817
 
815
818
  if (data.message.role === 'user') {
819
+ // Find pending message by matching content to avoid duplicates
816
820
  const pending = outputEl.querySelector('.message-sending');
817
821
  if (pending) {
818
822
  pending.id = '';
@@ -826,6 +830,12 @@ class AgentGUIClient {
826
830
  this.emit('message:created', data);
827
831
  return;
828
832
  }
833
+ // Check if a user message with this ID already exists (prevents duplicate on race condition)
834
+ const existingMsg = outputEl.querySelector(`[data-msg-id="${data.message.id}"]`);
835
+ if (existingMsg) {
836
+ this.emit('message:created', data);
837
+ return;
838
+ }
829
839
  }
830
840
 
831
841
  const messageHtml = `
@@ -1826,7 +1836,7 @@ class AgentGUIClient {
1826
1836
  }
1827
1837
 
1828
1838
  _updateConnectionIndicator(quality) {
1829
- if (this._indicatorDebounce) return;
1839
+ if (this._indicatorDebounce && !this._modelDownloadInProgress) return;
1830
1840
  this._indicatorDebounce = true;
1831
1841
  setTimeout(() => { this._indicatorDebounce = false; }, 1000);
1832
1842
 
@@ -1848,6 +1858,21 @@ class AgentGUIClient {
1848
1858
  const label = indicator.querySelector('.connection-label');
1849
1859
  if (!dot || !label) return;
1850
1860
 
1861
+ // Check if model download is in progress
1862
+ if (this._modelDownloadInProgress) {
1863
+ dot.className = 'connection-dot downloading';
1864
+ const progress = this._modelDownloadProgress;
1865
+ if (progress && progress.totalBytes > 0) {
1866
+ const pct = Math.round((progress.totalDownloaded / progress.totalBytes) * 100);
1867
+ label.textContent = `Models ${pct}%`;
1868
+ } else if (progress && progress.downloading) {
1869
+ label.textContent = 'Downloading...';
1870
+ } else {
1871
+ label.textContent = 'Loading models...';
1872
+ }
1873
+ return;
1874
+ }
1875
+
1851
1876
  dot.className = 'connection-dot';
1852
1877
  if (quality === 'disconnected' || quality === 'reconnecting') {
1853
1878
  dot.classList.add(quality);
@@ -1859,6 +1884,29 @@ class AgentGUIClient {
1859
1884
  }
1860
1885
  }
1861
1886
 
1887
+ _handleModelDownloadProgress(progress) {
1888
+ this._modelDownloadProgress = progress;
1889
+
1890
+ if (progress.error) {
1891
+ this._modelDownloadInProgress = false;
1892
+ console.error('[Models] Download error:', progress.error);
1893
+ this._updateConnectionIndicator(this.wsManager?.latency?.quality || 'unknown');
1894
+ return;
1895
+ }
1896
+
1897
+ if (progress.done) {
1898
+ this._modelDownloadInProgress = false;
1899
+ console.log('[Models] Download complete');
1900
+ this._updateConnectionIndicator(this.wsManager?.latency?.quality || 'unknown');
1901
+ return;
1902
+ }
1903
+
1904
+ if (progress.started || progress.downloading) {
1905
+ this._modelDownloadInProgress = true;
1906
+ this._updateConnectionIndicator(this.wsManager?.latency?.quality || 'unknown');
1907
+ }
1908
+ }
1909
+
1862
1910
  _toggleConnectionTooltip() {
1863
1911
  let tooltip = document.getElementById('connection-tooltip');
1864
1912
  if (tooltip) { tooltip.remove(); return; }
@@ -13,6 +13,7 @@ class ConversationManager {
13
13
  this.newBtn = document.querySelector('[data-new-conversation]');
14
14
  this.sidebarEl = document.querySelector('[data-sidebar]');
15
15
  this.streamingConversations = new Set();
16
+ this.agents = new Map();
16
17
 
17
18
  this.folderBrowser = {
18
19
  modal: null,
@@ -32,6 +33,7 @@ class ConversationManager {
32
33
  async init() {
33
34
  this.newBtn?.addEventListener('click', () => this.openFolderBrowser());
34
35
  this.setupDelegatedListeners();
36
+ await this.loadAgents();
35
37
  this.loadConversations();
36
38
  this.setupWebSocketListener();
37
39
  this.setupFolderBrowser();
@@ -40,6 +42,25 @@ class ConversationManager {
40
42
  setInterval(() => this.loadConversations(), 30000);
41
43
  }
42
44
 
45
+ async loadAgents() {
46
+ try {
47
+ const res = await fetch((window.__BASE_URL || '') + '/api/agents');
48
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
49
+ const data = await res.json();
50
+ for (const agent of data.agents || []) {
51
+ this.agents.set(agent.id, agent);
52
+ }
53
+ } catch (err) {
54
+ console.error('[ConversationManager] Error loading agents:', err);
55
+ }
56
+ }
57
+
58
+ getAgentDisplayName(agentId) {
59
+ if (!agentId) return 'Unknown';
60
+ const agent = this.agents.get(agentId);
61
+ return agent?.name || agentId;
62
+ }
63
+
43
64
  setupDelegatedListeners() {
44
65
  this.listEl.addEventListener('click', (e) => {
45
66
  const deleteBtn = e.target.closest('[data-delete-conv]');
@@ -375,7 +396,7 @@ class ConversationManager {
375
396
  const isStreaming = this.streamingConversations.has(conv.id);
376
397
  const title = conv.title || `Conversation ${conv.id.slice(0, 8)}`;
377
398
  const timestamp = conv.created_at ? new Date(conv.created_at).toLocaleDateString() : 'Unknown';
378
- const agent = conv.agentType || 'unknown';
399
+ const agent = this.getAgentDisplayName(conv.agentType);
379
400
  const modelLabel = conv.model ? ` (${conv.model})` : '';
380
401
  const wd = conv.workingDirectory ? conv.workingDirectory.split('/').pop() : '';
381
402
  const metaParts = [agent + modelLabel, timestamp];
@@ -403,7 +424,7 @@ class ConversationManager {
403
424
 
404
425
  const title = conv.title || `Conversation ${conv.id.slice(0, 8)}`;
405
426
  const timestamp = conv.created_at ? new Date(conv.created_at).toLocaleDateString() : 'Unknown';
406
- const agent = conv.agentType || 'unknown';
427
+ const agent = this.getAgentDisplayName(conv.agentType);
407
428
  const modelLabel = conv.model ? ` (${conv.model})` : '';
408
429
  const wd = conv.workingDirectory ? conv.workingDirectory.split('/').pop() : '';
409
430
  const metaParts = [agent + modelLabel, timestamp];
@@ -15,6 +15,8 @@
15
15
  var spokenChunks = new Set();
16
16
  var renderedSeqs = new Set();
17
17
  var isLoadingHistory = false;
18
+ var _lastVoiceBlockText = null;
19
+ var _lastVoiceBlockTime = 0;
18
20
  var selectedVoiceId = localStorage.getItem('voice-selected-id') || 'default';
19
21
  var ttsAudioCache = new Map();
20
22
  var TTS_CLIENT_CACHE_MAX = 50;
@@ -646,6 +648,14 @@
646
648
  function handleVoiceBlock(block, isNew) {
647
649
  if (!block || !block.type) return;
648
650
  if (block.type === 'text' && block.text) {
651
+ // Deduplicate: prevent rendering the same text block twice within 500ms
652
+ var now = Date.now();
653
+ if (_lastVoiceBlockText === block.text && (now - _lastVoiceBlockTime) < 500) {
654
+ return;
655
+ }
656
+ _lastVoiceBlockText = block.text;
657
+ _lastVoiceBlockTime = now;
658
+
649
659
  var div = addVoiceBlock(block.text, false);
650
660
  if (div && isNew && ttsEnabled) {
651
661
  div.classList.add('speaking');
@@ -661,6 +671,9 @@
661
671
  var container = document.getElementById('voiceMessages');
662
672
  if (!container) return;
663
673
  container.innerHTML = '';
674
+ // Reset dedup state when loading a new conversation
675
+ _lastVoiceBlockText = null;
676
+ _lastVoiceBlockTime = 0;
664
677
  if (!conversationId) {
665
678
  showVoiceEmpty(container);
666
679
  return;