agentgui 1.0.437 → 1.0.438

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -17,6 +17,7 @@ Server starts on `http://localhost:3000`, redirects to `/gm/`.
17
17
  server.js HTTP server + WebSocket + all API routes (raw http.createServer)
18
18
  database.js SQLite setup (WAL mode), schema, query functions
19
19
  lib/claude-runner.js Agent framework - spawns CLI processes, parses stream-json output
20
+ lib/acp-manager.js ACP tool lifecycle - auto-starts opencode/kilo HTTP servers, restart on crash
20
21
  lib/speech.js Speech-to-text and text-to-speech via @huggingface/transformers
21
22
  bin/gmgui.cjs CLI entry point (npx agentgui / bunx agentgui)
22
23
  static/index.html Main HTML shell
@@ -49,6 +50,14 @@ static/templates/ 31 HTML template fragments for event rendering
49
50
  - `STARTUP_CWD` - Working directory passed to agents
50
51
  - `HOT_RELOAD` - Set to "false" to disable watch mode
51
52
 
53
+ ## ACP Tool Lifecycle
54
+
55
+ On startup, agentgui auto-launches bundled ACP tools (opencode, kilo) as HTTP servers:
56
+ - OpenCode: port 18100 (`opencode acp --port 18100`)
57
+ - Kilo: port 18101 (`kilo acp --port 18101`)
58
+
59
+ Managed by `lib/acp-manager.js`. Features: crash restart with exponential backoff (max 10 in 5min), health checks every 30s via `GET /provider`, clean shutdown on SIGTERM. The `acpPort` field on discovered agents is set automatically once healthy. Models are queried from the running ACP HTTP servers via their `/provider` endpoint.
60
+
52
61
  ## REST API
53
62
 
54
63
  All routes are prefixed with `BASE_URL` (default `/gm`).
@@ -68,6 +77,7 @@ All routes are prefixed with `BASE_URL` (default `/gm`).
68
77
  - `GET /api/sessions/:id/chunks` - Get session chunks (query: since)
69
78
  - `GET /api/sessions/:id/execution` - Get execution events (query: limit, offset, filterType)
70
79
  - `GET /api/agents` - List discovered agents
80
+ - `GET /api/acp/status` - ACP tool lifecycle status (ports, health, PIDs, restart counts)
71
81
  - `GET /api/home` - Get home directory
72
82
  - `POST /api/stt` - Speech-to-text (raw audio body)
73
83
  - `POST /api/tts` - Text-to-speech (body: text)
package/build-portable.js CHANGED
@@ -122,6 +122,32 @@ const audioDeps = new Set();
122
122
  collectDeps('audio-decode', audioDeps);
123
123
  for (const dep of audioDeps) copyPkg(dep);
124
124
 
125
+ log('Copying ACP tools (opencode, kilo, gemini) for portable...');
126
+ const acpNativeTools = [
127
+ { wrapper: 'opencode-ai', platformPrefix: 'opencode-windows-x64' },
128
+ { wrapper: '@kilocode/cli', platformPrefix: '@kilocode/cli-windows-x64' },
129
+ ];
130
+ for (const tool of acpNativeTools) {
131
+ const wrapperSrc = path.join(nm, tool.wrapper);
132
+ if (fs.existsSync(wrapperSrc)) copyDir(wrapperSrc, path.join(destNm, tool.wrapper));
133
+ for (const suffix of ['', '-baseline']) {
134
+ const platPkg = tool.platformPrefix + suffix;
135
+ const platSrc = path.join(nm, platPkg);
136
+ if (fs.existsSync(platSrc)) {
137
+ copyDir(platSrc, path.join(destNm, platPkg));
138
+ log(` ${platPkg} (${Math.round(sizeOf(platSrc) / 1024 / 1024)}MB)`);
139
+ }
140
+ }
141
+ }
142
+ const geminiSrc = path.join(nm, '@google', 'gemini-cli');
143
+ if (fs.existsSync(geminiSrc)) {
144
+ copyDir(geminiSrc, path.join(destNm, '@google', 'gemini-cli'));
145
+ const geminiDeps = new Set();
146
+ collectDeps('@google/gemini-cli', geminiDeps);
147
+ for (const dep of geminiDeps) copyPkg(dep);
148
+ log(` gemini-cli + ${geminiDeps.size} deps`);
149
+ }
150
+
125
151
  log('Copying @anthropic-ai/claude-code ripgrep (win32)...');
126
152
  const claudeSrc = path.join(nm, '@anthropic-ai', 'claude-code');
127
153
  const claudeDest = path.join(destNm, '@anthropic-ai', 'claude-code');
@@ -157,17 +183,8 @@ if (process.env.NO_BUNDLE_MODELS === 'true') {
157
183
  }
158
184
  }
159
185
 
160
- fs.writeFileSync(path.join(out, 'README.txt'), [
161
- '# AgentGUI Portable',
162
- '',
163
- 'No installation required. Double-click agentgui.exe to start.',
164
- '',
165
- 'Web interface: http://localhost:3000/gm/',
166
- '',
167
- 'Data is stored in the data/ folder next to the executable.',
168
- '',
169
- 'Requirements: None - fully self-contained.',
170
- ].join('\n'));
186
+ fs.writeFileSync(path.join(out, 'README.txt'),
187
+ '# AgentGUI Portable\n\nDouble-click agentgui.exe to start.\nWeb interface: http://localhost:3000/gm/\nData stored in data/ folder. Fully self-contained.\n');
171
188
 
172
189
  const totalMB = Math.round(sizeOf(out) / 1024 / 1024);
173
190
  log(`Build complete! Total: ${totalMB}MB Output: ${out}`);
@@ -0,0 +1,179 @@
1
+ import { spawn } from 'child_process';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import fs from 'fs';
5
+ import { fileURLToPath } from 'url';
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const projectRoot = path.resolve(__dirname, '..');
9
+ const isWindows = os.platform() === 'win32';
10
+
11
+ const ACP_TOOLS = [
12
+ { id: 'opencode', cmd: 'opencode', args: ['acp'], port: 18100, npxPkg: 'opencode-ai' },
13
+ { id: 'kilo', cmd: 'kilo', args: ['acp'], port: 18101, npxPkg: '@kilocode/cli' },
14
+ ];
15
+
16
+ const MAX_RESTARTS = 10;
17
+ const RESTART_WINDOW_MS = 300000;
18
+ const HEALTH_INTERVAL_MS = 30000;
19
+ const STARTUP_GRACE_MS = 5000;
20
+ const processes = new Map();
21
+ let healthTimer = null;
22
+ let shuttingDown = false;
23
+
24
+ function log(msg) { console.log('[ACP] ' + msg); }
25
+
26
+ function resolveBinary(cmd) {
27
+ const ext = isWindows ? '.cmd' : '';
28
+ const localBin = path.join(projectRoot, 'node_modules', '.bin', cmd + ext);
29
+ if (fs.existsSync(localBin)) return localBin;
30
+ return cmd;
31
+ }
32
+
33
+ function startProcess(tool) {
34
+ if (shuttingDown) return null;
35
+ const bin = resolveBinary(tool.cmd);
36
+ const args = [...tool.args, '--port', String(tool.port)];
37
+ const opts = { stdio: ['pipe', 'pipe', 'pipe'], cwd: process.cwd() };
38
+ if (isWindows) opts.shell = true;
39
+
40
+ let proc;
41
+ try { proc = spawn(bin, args, opts); }
42
+ catch (err) { log(tool.id + ' spawn failed: ' + err.message); return null; }
43
+
44
+ const entry = {
45
+ id: tool.id, port: tool.port, process: proc, pid: proc.pid,
46
+ startedAt: Date.now(), restarts: [], healthy: false, lastHealthCheck: 0,
47
+ };
48
+
49
+ proc.stdout.on('data', () => {});
50
+ proc.stderr.on('data', (d) => {
51
+ const t = d.toString().trim();
52
+ if (t) log(tool.id + ': ' + t.substring(0, 200));
53
+ });
54
+ proc.stdout.on('error', () => {});
55
+ proc.stderr.on('error', () => {});
56
+ proc.on('error', (err) => { log(tool.id + ' error: ' + err.message); entry.healthy = false; });
57
+
58
+ proc.on('close', (code) => {
59
+ entry.healthy = false;
60
+ if (shuttingDown) return;
61
+ log(tool.id + ' exited code ' + code);
62
+ scheduleRestart(tool, entry.restarts);
63
+ });
64
+
65
+ processes.set(tool.id, entry);
66
+ log(tool.id + ' started port ' + tool.port + ' pid ' + proc.pid);
67
+ setTimeout(() => checkHealth(tool.id), STARTUP_GRACE_MS);
68
+ return entry;
69
+ }
70
+
71
+ function scheduleRestart(tool, prevRestarts = []) {
72
+ if (shuttingDown) return;
73
+ const now = Date.now();
74
+ const recent = prevRestarts.filter(t => now - t < RESTART_WINDOW_MS);
75
+ if (recent.length >= MAX_RESTARTS) {
76
+ log(tool.id + ' exceeded restart limit, giving up');
77
+ processes.delete(tool.id);
78
+ return;
79
+ }
80
+ const delay = Math.min(1000 * Math.pow(2, recent.length), 30000);
81
+ log(tool.id + ' restarting in ' + delay + 'ms');
82
+ setTimeout(() => {
83
+ if (shuttingDown) return;
84
+ const entry = startProcess(tool);
85
+ if (entry) entry.restarts = [...recent, Date.now()];
86
+ }, delay);
87
+ }
88
+
89
+ async function checkHealth(toolId) {
90
+ const entry = processes.get(toolId);
91
+ if (!entry || shuttingDown) return;
92
+ try {
93
+ const res = await fetch('http://127.0.0.1:' + entry.port + '/provider', {
94
+ signal: AbortSignal.timeout(3000), headers: { 'Accept': 'application/json' }
95
+ });
96
+ entry.healthy = res.ok;
97
+ } catch (_) { entry.healthy = false; }
98
+ entry.lastHealthCheck = Date.now();
99
+ }
100
+
101
+ export async function startAll() {
102
+ log('starting ACP tools...');
103
+ for (const tool of ACP_TOOLS) {
104
+ const bin = resolveBinary(tool.cmd);
105
+ if (bin === tool.cmd && !fs.existsSync(bin)) {
106
+ log(tool.id + ' not found, skipping');
107
+ continue;
108
+ }
109
+ startProcess(tool);
110
+ }
111
+ healthTimer = setInterval(() => {
112
+ for (const [id] of processes) checkHealth(id);
113
+ }, HEALTH_INTERVAL_MS);
114
+ }
115
+
116
+ export async function stopAll() {
117
+ shuttingDown = true;
118
+ if (healthTimer) { clearInterval(healthTimer); healthTimer = null; }
119
+ const kills = [];
120
+ for (const [id, entry] of processes) {
121
+ log('stopping ' + id + ' pid ' + entry.pid);
122
+ kills.push(new Promise(resolve => {
123
+ const t = setTimeout(() => { try { entry.process.kill('SIGKILL'); } catch (_) {} resolve(); }, 5000);
124
+ entry.process.on('close', () => { clearTimeout(t); resolve(); });
125
+ try { entry.process.kill('SIGTERM'); } catch (_) {}
126
+ }));
127
+ }
128
+ await Promise.all(kills);
129
+ processes.clear();
130
+ log('all stopped');
131
+ }
132
+
133
+ export function getStatus() {
134
+ return Array.from(processes.values()).map(e => ({
135
+ id: e.id, port: e.port, pid: e.pid, healthy: e.healthy,
136
+ uptime: Date.now() - e.startedAt, restartCount: e.restarts.length,
137
+ }));
138
+ }
139
+
140
+ export function getPort(agentId) {
141
+ const e = processes.get(agentId);
142
+ return e?.healthy ? e.port : null;
143
+ }
144
+
145
+ export function getRunningPorts() {
146
+ const ports = {};
147
+ for (const [id, e] of processes) if (e.healthy) ports[id] = e.port;
148
+ return ports;
149
+ }
150
+
151
+ export async function restart(agentId) {
152
+ const tool = ACP_TOOLS.find(t => t.id === agentId);
153
+ if (!tool) return false;
154
+ const entry = processes.get(agentId);
155
+ if (entry) { try { entry.process.kill('SIGTERM'); } catch (_) {} processes.delete(agentId); }
156
+ startProcess(tool);
157
+ return true;
158
+ }
159
+
160
+ export async function queryModels(agentId) {
161
+ const entry = processes.get(agentId);
162
+ if (!entry?.healthy) return [];
163
+ try {
164
+ const res = await fetch('http://127.0.0.1:' + entry.port + '/provider', {
165
+ signal: AbortSignal.timeout(5000), headers: { 'Accept': 'application/json' }
166
+ });
167
+ if (!res.ok) return [];
168
+ const data = await res.json();
169
+ const models = [];
170
+ for (const prov of (data.all || [])) {
171
+ for (const m of Object.values(prov.models || {})) {
172
+ models.push({ id: m.id, label: m.name || m.id, provider: prov.name || prov.id });
173
+ }
174
+ }
175
+ return models;
176
+ } catch (_) { return []; }
177
+ }
178
+
179
+ export const ACP_TOOL_CONFIGS = ACP_TOOLS;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.437",
3
+ "version": "1.0.438",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "server.js",
@@ -33,7 +33,7 @@
33
33
  "fsbrowse": "^0.2.18",
34
34
  "google-auth-library": "^10.5.0",
35
35
  "onnxruntime-node": "1.21.0",
36
- "opencode-ai": "latest",
36
+ "opencode-ai": "^1.2.15",
37
37
  "puppeteer-core": "^24.37.5",
38
38
  "webtalk": "^1.0.31",
39
39
  "ws": "^8.14.2"
package/server.js CHANGED
@@ -22,6 +22,7 @@ import { register as registerConvHandlers } from './lib/ws-handlers-conv.js';
22
22
  import { register as registerSessionHandlers } from './lib/ws-handlers-session.js';
23
23
  import { register as registerRunHandlers } from './lib/ws-handlers-run.js';
24
24
  import { register as registerUtilHandlers } from './lib/ws-handlers-util.js';
25
+ import { startAll as startACPTools, stopAll as stopACPTools, getStatus as getACPStatus, getPort as getACPPort, queryModels as queryACPModels } from './lib/acp-manager.js';
25
26
 
26
27
 
27
28
  process.on('uncaughtException', (err, origin) => {
@@ -438,18 +439,24 @@ async function getModelsForAgent(agentId) {
438
439
  ];
439
440
  } else {
440
441
  const agent = discoveredAgents.find(a => a.id === agentId);
441
- if (agent?.protocol === 'acp' && agent.acpPort) {
442
- try {
443
- const res = await fetch(`http://localhost:${agent.acpPort}/models`, {
444
- headers: { 'Content-Type': 'application/json' },
445
- signal: AbortSignal.timeout(3000)
446
- });
447
- if (res.ok) {
448
- const data = await res.json();
449
- const list = data?.data || data?.models || (Array.isArray(data) ? data : []);
450
- models = list.map(m => ({ id: m.id || m.model_id, label: m.name || m.display_name || m.id || m.model_id })).filter(m => m.id);
451
- }
452
- } catch (_) {}
442
+ if (agent?.protocol === 'acp') {
443
+ const acpPort = getACPPort(agentId) || agent.acpPort;
444
+ if (acpPort) {
445
+ try {
446
+ models = await queryACPModels(agentId);
447
+ if (!models.length) {
448
+ const res = await fetch(`http://localhost:${acpPort}/models`, {
449
+ headers: { 'Content-Type': 'application/json' },
450
+ signal: AbortSignal.timeout(3000)
451
+ });
452
+ if (res.ok) {
453
+ const data = await res.json();
454
+ const list = data?.data || data?.models || (Array.isArray(data) ? data : []);
455
+ models = list.map(m => ({ id: m.id || m.model_id, label: m.name || m.display_name || m.id || m.model_id })).filter(m => m.id);
456
+ }
457
+ }
458
+ } catch (_) {}
459
+ }
453
460
  }
454
461
  }
455
462
  modelCache.set(agentId, { models, timestamp: Date.now() });
@@ -1747,6 +1754,11 @@ const server = http.createServer(async (req, res) => {
1747
1754
  return;
1748
1755
  }
1749
1756
 
1757
+ if (pathOnly === '/api/acp/status' && req.method === 'GET') {
1758
+ sendJSON(req, res, 200, { tools: getACPStatus() });
1759
+ return;
1760
+ }
1761
+
1750
1762
  if (pathOnly === '/api/ws-stats' && req.method === 'GET') {
1751
1763
  const stats = wsOptimizer.getStats();
1752
1764
  sendJSON(req, res, 200, stats);
@@ -3965,7 +3977,11 @@ if (watch) {
3965
3977
 
3966
3978
  process.on('SIGTERM', () => {
3967
3979
  console.log('[SIGNAL] SIGTERM received - graceful shutdown');
3968
- wss.close(() => server.close(() => process.exit(0)));
3980
+ stopACPTools().then(() => {
3981
+ wss.close(() => server.close(() => process.exit(0)));
3982
+ }).catch(() => {
3983
+ wss.close(() => server.close(() => process.exit(0)));
3984
+ });
3969
3985
  });
3970
3986
 
3971
3987
  server.on('error', (err) => {
@@ -4168,6 +4184,21 @@ function onServerReady() {
4168
4184
 
4169
4185
  resumeInterruptedStreams().catch(err => console.error('[RESUME] Startup error:', err.message));
4170
4186
 
4187
+ startACPTools().then(() => {
4188
+ setTimeout(() => {
4189
+ const acpStatus = getACPStatus();
4190
+ for (const s of acpStatus) {
4191
+ if (s.healthy) {
4192
+ const agent = discoveredAgents.find(a => a.id === s.id);
4193
+ if (agent) { agent.acpPort = s.port; }
4194
+ }
4195
+ }
4196
+ if (acpStatus.length > 0) {
4197
+ console.log(`[ACP] Tools ready: ${acpStatus.filter(s => s.healthy).map(s => s.id + ':' + s.port).join(', ') || 'none healthy yet'}`);
4198
+ }
4199
+ }, 6000);
4200
+ }).catch(err => console.error('[ACP] Startup error:', err.message));
4201
+
4171
4202
  ensureModelsDownloaded().then(async ok => {
4172
4203
  if (ok) console.log('[MODELS] Speech models ready');
4173
4204
  else console.log('[MODELS] Speech model download failed');