agentgui 1.0.694 → 1.0.695

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.
@@ -3,6 +3,7 @@ import path from 'path';
3
3
  import os from 'os';
4
4
  import fs from 'fs';
5
5
  import { fileURLToPath } from 'url';
6
+ import pRetry, { AbortError } from 'p-retry';
6
7
 
7
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
9
  const projectRoot = path.resolve(__dirname, '..');
@@ -67,13 +68,11 @@ function startProcess(tool) {
67
68
  log(tool.id + ' exited code ' + code);
68
69
  const window = Date.now() - RESTART_WINDOW_MS;
69
70
  entry.restarts = entry.restarts.filter(t => t > window);
70
- if (entry.restarts.length < MAX_RESTARTS) {
71
- const delay = Math.min(1000 * Math.pow(2, entry.restarts.length), 30000);
72
- entry.restarts.push(Date.now());
73
- setTimeout(() => startProcess(tool), delay);
74
- } else {
75
- log(tool.id + ' max restarts reached');
76
- }
71
+ if (entry.restarts.length >= MAX_RESTARTS) { log(tool.id + ' max restarts reached'); return; }
72
+ const attempt = entry.restarts.length;
73
+ entry.restarts.push(Date.now());
74
+ const delay = Math.min(1000 * Math.pow(2, attempt), 30000);
75
+ setTimeout(() => { if (!shuttingDown) startProcess(tool); }, delay);
77
76
  });
78
77
 
79
78
  processes.set(tool.id, entry);
@@ -133,12 +132,17 @@ export async function ensureRunning(agentId) {
133
132
  entry = startProcess(tool);
134
133
  if (!entry) return null;
135
134
  }
136
- for (let i = 0; i < 20; i++) {
137
- await new Promise(r => setTimeout(r, 500));
138
- await checkHealth(agentId);
139
- if (processes.get(agentId)?.healthy) { resetIdleTimer(agentId); return tool.port; }
135
+ try {
136
+ await pRetry(async () => {
137
+ if (shuttingDown) throw new AbortError('shutting down');
138
+ await checkHealth(agentId);
139
+ if (!processes.get(agentId)?.healthy) throw new Error('not healthy yet');
140
+ }, { retries: 20, minTimeout: 500, maxTimeout: 500, factor: 1 });
141
+ resetIdleTimer(agentId);
142
+ return tool.port;
143
+ } catch {
144
+ return null;
140
145
  }
141
- return null;
142
146
  }
143
147
 
144
148
  export function touch(agentId) {
@@ -1,4 +1,5 @@
1
- import { spawn, spawnSync } from 'child_process';
1
+ import { spawnSync } from 'child_process';
2
+ import { execa } from 'execa';
2
3
 
3
4
  const isWindows = process.platform === 'win32';
4
5
 
@@ -80,191 +81,135 @@ class AgentRunner {
80
81
  }
81
82
 
82
83
  async runDirect(prompt, cwd, config = {}) {
83
- return new Promise((resolve, reject) => {
84
- const {
85
- timeout = 300000,
86
- onEvent = null,
87
- onError = null,
88
- onRateLimit = null
89
- } = config;
90
-
91
- if (process.env.DEBUG === '1') {
92
- const sp = config.systemPrompt;
93
- console.error(`[prompt-trace] convId=${config.conversationId} promptType=${typeof prompt} promptLen=${String(prompt).length} prompt0=${String(prompt).slice(0, 100)} sysLen=${sp ? String(sp).length : 0}`);
94
- }
95
- const args = this.buildArgs(prompt, config);
96
- const spawnOpts = getSpawnOptions(cwd);
97
- if (Object.keys(this.spawnEnv).length > 0) {
98
- spawnOpts.env = { ...spawnOpts.env, ...this.spawnEnv };
99
- for (const [k, v] of Object.entries(this.spawnEnv)) {
100
- if (v === undefined) delete spawnOpts.env[k];
101
- }
102
- }
103
- // Tell hooks the actual project dir (used by mcp-thorns/codebasesearch)
104
- if (cwd) spawnOpts.env.CLAUDE_PROJECT_DIR = cwd;
105
- if (this.closeStdin) {
106
- spawnOpts.stdio = ['ignore', 'pipe', 'pipe'];
107
- }
108
- const proc = spawn(this.command, args, spawnOpts);
109
- console.log(`[${this.id}] Spawned PID ${proc.pid} closeStdin=${this.closeStdin}`);
110
-
111
- if (config.onPid) {
112
- try { config.onPid(proc.pid); } catch (e) {}
113
- }
114
-
115
- if (config.onProcess) {
116
- try { config.onProcess(proc); } catch (e) {}
117
- }
118
-
119
- let jsonBuffer = '';
120
- const outputs = [];
121
- let timedOut = false;
122
- let sessionId = null;
123
- let rateLimited = false;
124
- let retryAfterSec = 60;
125
- let authError = false;
126
- let authErrorMessage = '';
127
- let stderrBuffer = '';
84
+ const {
85
+ timeout = 300000,
86
+ onEvent = null,
87
+ onError = null,
88
+ onRateLimit = null
89
+ } = config;
128
90
 
129
- const timeoutHandle = setTimeout(() => {
130
- timedOut = true;
131
- proc.kill();
132
- reject(new Error(`${this.name} timeout after ${timeout}ms`));
133
- }, timeout);
91
+ if (process.env.DEBUG === '1') {
92
+ const sp = config.systemPrompt;
93
+ console.error(`[prompt-trace] convId=${config.conversationId} promptType=${typeof prompt} promptLen=${String(prompt).length} prompt0=${String(prompt).slice(0, 100)} sysLen=${sp ? String(sp).length : 0}`);
94
+ }
134
95
 
135
- // Write prompt to stdin only for agents that use stdin protocol (not positional args)
136
- if (this.supportsStdin && this.stdinPrompt) {
137
- proc.stdin.write(typeof prompt === 'string' ? prompt : String(prompt));
138
- // Don't call stdin.end() - agents need open stdin for steering
96
+ const args = this.buildArgs(prompt, config);
97
+ const spawnOpts = getSpawnOptions(cwd);
98
+ if (Object.keys(this.spawnEnv).length > 0) {
99
+ spawnOpts.env = { ...spawnOpts.env, ...this.spawnEnv };
100
+ for (const [k, v] of Object.entries(this.spawnEnv)) {
101
+ if (v === undefined) delete spawnOpts.env[k];
139
102
  }
103
+ }
104
+ if (cwd) spawnOpts.env.CLAUDE_PROJECT_DIR = cwd;
105
+
106
+ const proc = execa(this.command, args, {
107
+ cwd,
108
+ env: spawnOpts.env,
109
+ stdin: this.closeStdin ? 'ignore' : 'pipe',
110
+ stdout: 'pipe',
111
+ stderr: 'pipe',
112
+ reject: false,
113
+ timeout,
114
+ windowsHide: true,
115
+ shell: isWindows,
116
+ });
140
117
 
141
- proc.stdout.on('error', () => {});
142
- if (proc.stderr) proc.stderr.on('error', () => {});
143
- proc.stdout.on('data', (chunk) => {
144
- if (timedOut) return;
145
-
146
- jsonBuffer += chunk.toString();
147
- const lines = jsonBuffer.split('\n');
148
- jsonBuffer = lines.pop();
149
-
150
- for (const line of lines) {
151
- if (line.trim()) {
152
- const parsed = this.parseOutput(line);
153
- if (!parsed) continue;
118
+ console.log(`[${this.id}] Spawned PID ${proc.pid} closeStdin=${this.closeStdin}`);
154
119
 
155
- outputs.push(parsed);
120
+ if (config.onPid) { try { config.onPid(proc.pid); } catch (e) {} }
121
+ if (config.onProcess) { try { config.onProcess(proc); } catch (e) {} }
156
122
 
157
- if (parsed.session_id) {
158
- sessionId = parsed.session_id;
159
- }
123
+ if (this.supportsStdin && this.stdinPrompt && proc.stdin) {
124
+ proc.stdin.write(typeof prompt === 'string' ? prompt : String(prompt));
125
+ }
160
126
 
161
- if (onEvent) {
162
- try { onEvent(parsed); } catch (e) {
163
- console.error(`[${this.id}] onEvent error: ${e.message}`);
164
- }
165
- }
127
+ const outputs = [];
128
+ let sessionId = null;
129
+ let rateLimited = false;
130
+ let retryAfterSec = 60;
131
+ let authError = false;
132
+ let authErrorMessage = '';
133
+ let stderrBuffer = '';
134
+
135
+ proc.stderr.on('data', (chunk) => {
136
+ const errorText = chunk.toString();
137
+ stderrBuffer += errorText;
138
+ console.error(`[${this.id}] stderr:`, errorText);
139
+ const authMatch = errorText.match(/401|unauthorized|invalid.*auth|invalid.*token|auth.*failed|permission denied|access denied/i);
140
+ if (authMatch) { authError = true; authErrorMessage = errorText.trim(); }
141
+ const rateLimitMatch = errorText.match(/rate.?limit|429|too many requests|overloaded|throttl|hit your limit/i);
142
+ if (rateLimitMatch) {
143
+ rateLimited = true;
144
+ const retryMatch = errorText.match(/retry.?after[:\s]+(\d+)/i);
145
+ if (retryMatch) {
146
+ retryAfterSec = parseInt(retryMatch[1], 10) || 60;
147
+ } else {
148
+ const resetTimeMatch = errorText.match(/resets?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*\(?(UTC|[A-Z]{2,4})\)?/i);
149
+ if (resetTimeMatch) {
150
+ let hours = parseInt(resetTimeMatch[1], 10);
151
+ const minutes = resetTimeMatch[2] ? parseInt(resetTimeMatch[2], 10) : 0;
152
+ const period = resetTimeMatch[3]?.toLowerCase();
153
+ if (period === 'pm' && hours !== 12) hours += 12;
154
+ if (period === 'am' && hours === 12) hours = 0;
155
+ const now = new Date();
156
+ const resetTime = new Date(now);
157
+ resetTime.setUTCHours(hours, minutes, 0, 0);
158
+ if (resetTime <= now) resetTime.setUTCDate(resetTime.getUTCDate() + 1);
159
+ retryAfterSec = Math.max(60, Math.ceil((resetTime.getTime() - now.getTime()) / 1000));
166
160
  }
167
161
  }
168
- });
169
-
170
- if (proc.stderr) proc.stderr.on('data', (chunk) => {
171
- const errorText = chunk.toString();
172
- stderrBuffer += errorText;
173
- console.error(`[${this.id}] stderr:`, errorText);
174
-
175
- const authMatch = errorText.match(/401|unauthorized|invalid.*auth|invalid.*token|auth.*failed|permission denied|access denied/i);
176
- if (authMatch) {
177
- authError = true;
178
- authErrorMessage = errorText.trim();
179
- }
180
-
181
- const rateLimitMatch = errorText.match(/rate.?limit|429|too many requests|overloaded|throttl|hit your limit/i);
182
- if (rateLimitMatch) {
183
- rateLimited = true;
184
- const retryMatch = errorText.match(/retry.?after[:\s]+(\d+)/i);
185
- if (retryMatch) {
186
- retryAfterSec = parseInt(retryMatch[1], 10) || 60;
187
- } else {
188
- const resetTimeMatch = errorText.match(/resets?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*\(?(UTC|[A-Z]{2,4})\)?/i);
189
- if (resetTimeMatch) {
190
- let hours = parseInt(resetTimeMatch[1], 10);
191
- const minutes = resetTimeMatch[2] ? parseInt(resetTimeMatch[2], 10) : 0;
192
- const period = resetTimeMatch[3]?.toLowerCase();
193
- const tz = resetTimeMatch[4]?.toUpperCase() || 'UTC';
194
-
195
- if (period === 'pm' && hours !== 12) hours += 12;
196
- if (period === 'am' && hours === 12) hours = 0;
197
-
198
- const now = new Date();
199
- const resetTime = new Date(now);
200
- resetTime.setUTCHours(hours, minutes, 0, 0);
201
-
202
- if (resetTime <= now) {
203
- resetTime.setUTCDate(resetTime.getUTCDate() + 1);
204
- }
162
+ }
163
+ if (onError) { try { onError(errorText); } catch (e) {} }
164
+ });
205
165
 
206
- retryAfterSec = Math.max(60, Math.ceil((resetTime.getTime() - now.getTime()) / 1000));
207
- }
208
- }
209
- }
166
+ let jsonBuffer = '';
167
+ proc.stdout.on('data', (chunk) => {
168
+ jsonBuffer += chunk.toString();
169
+ const lines = jsonBuffer.split('\n');
170
+ jsonBuffer = lines.pop();
171
+ for (const line of lines) {
172
+ if (!line.trim()) continue;
173
+ const parsed = this.parseOutput(line);
174
+ if (!parsed) continue;
175
+ outputs.push(parsed);
176
+ if (parsed.session_id) sessionId = parsed.session_id;
177
+ if (onEvent) { try { onEvent(parsed); } catch (e) { console.error(`[${this.id}] onEvent error: ${e.message}`); } }
178
+ }
179
+ });
210
180
 
211
- if (onError) {
212
- try { onError(errorText); } catch (e) {}
213
- }
214
- });
181
+ const result = await proc;
215
182
 
216
- proc.on('close', (code) => {
217
- clearTimeout(timeoutHandle);
218
- // Close stdin when process exits - it was kept open for steering during execution
219
- if (proc.stdin && !proc.stdin.destroyed) {
220
- try { proc.stdin.end(); } catch (e) {}
221
- }
222
- if (timedOut) return;
183
+ if (proc.stdin && !proc.stdin.destroyed) { try { proc.stdin.end(); } catch (e) {} }
223
184
 
224
- if (authError) {
225
- const err = new Error(`Authentication failed: ${authErrorMessage || 'Invalid credentials or unauthorized access'}`);
226
- err.authError = true;
227
- err.nonRetryable = true;
228
- reject(err);
229
- return;
230
- }
185
+ if (jsonBuffer.trim()) {
186
+ const parsed = this.parseOutput(jsonBuffer);
187
+ if (parsed) {
188
+ outputs.push(parsed);
189
+ if (parsed.session_id) sessionId = parsed.session_id;
190
+ if (onEvent) { try { onEvent(parsed); } catch (e) {} }
191
+ }
192
+ }
231
193
 
232
- if (rateLimited) {
233
- const err = new Error(`Rate limited - retry after ${retryAfterSec}s`);
234
- err.rateLimited = true;
235
- err.retryAfterSec = retryAfterSec;
236
- if (onRateLimit) {
237
- try { onRateLimit({ retryAfterSec }); } catch (e) {}
238
- }
239
- reject(err);
240
- return;
241
- }
194
+ if (result.timedOut) throw new Error(`${this.name} timeout after ${timeout}ms`);
242
195
 
243
- if (jsonBuffer.trim()) {
244
- const parsed = this.parseOutput(jsonBuffer);
245
- if (parsed) {
246
- outputs.push(parsed);
247
- if (parsed.session_id) sessionId = parsed.session_id;
248
- if (onEvent) {
249
- try { onEvent(parsed); } catch (e) {}
250
- }
251
- }
252
- }
196
+ if (authError) {
197
+ const err = new Error(`Authentication failed: ${authErrorMessage || 'Invalid credentials or unauthorized access'}`);
198
+ err.authError = true; err.nonRetryable = true; throw err;
199
+ }
253
200
 
254
- if (code === 0 || outputs.length > 0) {
255
- resolve({ outputs, sessionId });
256
- } else {
257
- const stderrHint = stderrBuffer.trim() ? `: ${stderrBuffer.trim().slice(0, 200)}` : '';
258
- const codeHint = code === 143 ? ' (SIGTERM - process was killed)' : code === 137 ? ' (SIGKILL - out of memory or force-killed)' : '';
259
- reject(new Error(`${this.name} exited with code ${code}${codeHint}${stderrHint}`));
260
- }
261
- });
201
+ if (rateLimited) {
202
+ const err = new Error(`Rate limited - retry after ${retryAfterSec}s`);
203
+ err.rateLimited = true; err.retryAfterSec = retryAfterSec;
204
+ if (onRateLimit) { try { onRateLimit({ retryAfterSec }); } catch (e) {} }
205
+ throw err;
206
+ }
262
207
 
263
- proc.on('error', (err) => {
264
- clearTimeout(timeoutHandle);
265
- reject(err);
266
- });
267
- });
208
+ const code = result.exitCode;
209
+ if (code === 0 || outputs.length > 0) return { outputs, sessionId };
210
+ const stderrHint = stderrBuffer.trim() ? `: ${stderrBuffer.trim().slice(0, 200)}` : '';
211
+ const codeHint = code === 143 ? ' (SIGTERM - process was killed)' : code === 137 ? ' (SIGKILL - out of memory or force-killed)' : '';
212
+ throw new Error(`${this.name} exited with code ${code}${codeHint}${stderrHint}`);
268
213
  }
269
214
 
270
215
  async runACP(prompt, cwd, config = {}, _retryCount = 0) {
@@ -1,10 +1,43 @@
1
1
  import path from 'path';
2
2
  import os from 'os';
3
+ import { z } from 'zod';
3
4
 
4
5
  function fail(code, message) { const e = new Error(message); e.code = code; throw e; }
5
6
  function notFound(msg = 'Not found') { fail(404, msg); }
6
7
  function expandTilde(p) { return p && p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : p; }
7
8
 
9
+ function validate(schema, params) {
10
+ const result = schema.safeParse(params);
11
+ if (!result.success) fail(400, result.error.issues.map(i => i.message).join('; '));
12
+ return result.data;
13
+ }
14
+
15
+ const ConvNewSchema = z.object({
16
+ agentId: z.string().optional(),
17
+ title: z.string().optional(),
18
+ workingDirectory: z.string().optional(),
19
+ model: z.string().optional(),
20
+ subAgent: z.string().optional(),
21
+ }).passthrough();
22
+
23
+ const ConvUpdSchema = z.object({
24
+ id: z.string().min(1, 'id required'),
25
+ }).passthrough();
26
+
27
+ const MsgStreamSchema = z.object({
28
+ id: z.string().min(1, 'conversation id required'),
29
+ content: z.union([z.string(), z.any()]).optional(),
30
+ message: z.union([z.string(), z.any()]).optional(),
31
+ agentId: z.string().optional(),
32
+ model: z.string().optional(),
33
+ subAgent: z.string().optional(),
34
+ }).passthrough();
35
+
36
+ const ConvSteerSchema = z.object({
37
+ id: z.string().min(1, 'conversation id required'),
38
+ content: z.union([z.string(), z.record(z.any())]).refine(v => v !== undefined && v !== null && v !== '', { message: 'content required' }),
39
+ }).passthrough();
40
+
8
41
  export function register(router, deps) {
9
42
  const { queries, activeExecutions, messageQueues, rateLimitState,
10
43
  broadcastSync, processMessageWithStreaming, cleanupExecution, logError = () => {} } = deps;
@@ -26,6 +59,7 @@ export function register(router, deps) {
26
59
  });
27
60
 
28
61
  router.handle('conv.new', (p) => {
62
+ p = validate(ConvNewSchema, p);
29
63
  const wd = p.workingDirectory ? path.resolve(expandTilde(p.workingDirectory)) : null;
30
64
  const conv = queries.createConversation(p.agentId, p.title, wd, p.model || null, p.subAgent || null);
31
65
  queries.createEvent('conversation.created', { agentId: p.agentId, workingDirectory: conv.workingDirectory, model: conv.model, subAgent: conv.subAgent }, conv.id);
@@ -40,6 +74,7 @@ export function register(router, deps) {
40
74
  });
41
75
 
42
76
  router.handle('conv.upd', (p) => {
77
+ p = validate(ConvUpdSchema, p);
43
78
  const { id, ...data } = p;
44
79
  if (data.workingDirectory) data.workingDirectory = path.resolve(data.workingDirectory);
45
80
  const conv = queries.updateConversation(id, data);
@@ -130,9 +165,9 @@ export function register(router, deps) {
130
165
  });
131
166
 
132
167
  router.handle('conv.steer', (p) => {
168
+ p = validate(ConvSteerSchema, p);
133
169
  const conv = queries.getConversation(p.id);
134
170
  if (!conv) notFound('Conversation not found');
135
- if (!p.content) fail(400, 'Missing content');
136
171
 
137
172
  const entry = activeExecutions.get(p.id);
138
173
  if (!entry) fail(409, 'No active execution to steer');
@@ -226,6 +261,7 @@ export function register(router, deps) {
226
261
  });
227
262
 
228
263
  router.handle('msg.stream', (p) => {
264
+ p = validate(MsgStreamSchema, p);
229
265
  const conv = queries.getConversation(p.id);
230
266
  if (!conv) notFound('Conversation not found');
231
267
  const rawContent = p.content || p.message;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.694",
3
+ "version": "1.0.695",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "server.js",
@@ -29,17 +29,21 @@
29
29
  "audio-decode": "^2.2.3",
30
30
  "better-sqlite3": "^12.6.2",
31
31
  "busboy": "^1.6.0",
32
+ "execa": "^9.6.1",
32
33
  "express": "^5.2.1",
33
34
  "form-data": "^4.0.5",
34
35
  "fsbrowse": "latest",
35
36
  "google-auth-library": "^10.5.0",
37
+ "lru-cache": "^11.2.7",
36
38
  "msgpackr": "^1.11.8",
37
39
  "onnxruntime-node": "1.21.0",
38
40
  "opencode-ai": "^1.2.15",
41
+ "p-retry": "^7.1.1",
39
42
  "pm2": "^5.4.3",
40
43
  "puppeteer-core": "^24.37.5",
41
44
  "webtalk": "^1.0.31",
42
- "ws": "^8.14.2"
45
+ "ws": "^8.14.2",
46
+ "zod": "^4.3.6"
43
47
  },
44
48
  "optionalDependencies": {
45
49
  "node-pty": "^1.0.0"
package/server.js CHANGED
@@ -7,6 +7,7 @@ import crypto from 'crypto';
7
7
  import { fileURLToPath } from 'url';
8
8
  import { WebSocketServer } from 'ws';
9
9
  import { execSync, spawn } from 'child_process';
10
+ import { LRUCache } from 'lru-cache';
10
11
  import { createRequire } from 'module';
11
12
  const PKG_VERSION = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version;
12
13
  import { OAuth2Client } from 'google-auth-library';
@@ -3277,8 +3278,8 @@ function generateETag(stats) {
3277
3278
  return `"${stats.mtimeMs.toString(36)}-${stats.size.toString(36)}"`;
3278
3279
  }
3279
3280
 
3280
- // In-memory cache: etag -> { br: Buffer, gz: Buffer, raw: Buffer }
3281
- const _assetCache = new Map();
3281
+ // In-memory cache: etag -> { gz: Buffer, raw: Buffer } (bounded to 200 entries)
3282
+ const _assetCache = new LRUCache({ max: 200 });
3282
3283
  // Cached processed HTML (invalidated on hot-reload or server restart)
3283
3284
  let _htmlCache = null;
3284
3285
  let _htmlCacheEtag = null;