agentgui 1.0.675 → 1.0.677

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.
@@ -156,7 +156,7 @@ const getPublishedVersion = async (pkg) => {
156
156
  const checkCliInstalled = (pkg) => {
157
157
  try {
158
158
  const cmd = isWindows ? 'where' : 'which';
159
- const binMap = { '@anthropic-ai/claude-code': 'claude', 'opencode-ai': 'opencode', '@google/gemini-cli': 'gemini', '@kilocode/cli': 'kilo', '@openai/codex': 'codex' };
159
+ const binMap = { '@anthropic-ai/claude-code': 'claude', 'opencode-ai': 'opencode', '@google/gemini-cli': 'gemini', '@kilocode/cli': 'kilo', '@openai/codex': 'codex', 'agent-browser': 'agent-browser' };
160
160
  const bin = binMap[pkg];
161
161
  if (bin) {
162
162
  execSync(`${cmd} ${bin}`, { stdio: 'pipe', timeout: 3000, windowsHide: true });
@@ -168,12 +168,14 @@ const checkCliInstalled = (pkg) => {
168
168
 
169
169
  const getCliVersion = (pkg) => {
170
170
  try {
171
- const binMap = { '@anthropic-ai/claude-code': 'claude', 'opencode-ai': 'opencode', '@google/gemini-cli': 'gemini', '@kilocode/cli': 'kilo', '@openai/codex': 'codex' };
171
+ const binMap = { '@anthropic-ai/claude-code': 'claude', 'opencode-ai': 'opencode', '@google/gemini-cli': 'gemini', '@kilocode/cli': 'kilo', '@openai/codex': 'codex', 'agent-browser': 'agent-browser' };
172
172
  const bin = binMap[pkg];
173
173
  if (bin) {
174
174
  try {
175
175
  // Use short timeout - we already know the binary exists from checkCliInstalled
176
- const out = execSync(`${bin} --version`, { stdio: 'pipe', timeout: 1000, encoding: 'utf8', windowsHide: true });
176
+ // agent-browser uses -V (--version prints help); others use --version
177
+ const versionFlag = pkg === 'agent-browser' ? '-V' : '--version';
178
+ const out = execSync(`${bin} ${versionFlag}`, { stdio: 'pipe', timeout: 1000, encoding: 'utf8', windowsHide: true });
177
179
  const match = out.match(/(\d+\.\d+\.\d+)/);
178
180
  if (match) {
179
181
  console.log(`[tool-manager] CLI ${pkg} (${bin}) version: ${match[1]}`);
@@ -7,7 +7,7 @@ function expandTilde(p) { return p && p.startsWith('~') ? path.join(os.homedir()
7
7
 
8
8
  export function register(router, deps) {
9
9
  const { queries, activeExecutions, messageQueues, rateLimitState,
10
- broadcastSync, processMessageWithStreaming, activeProcessesByConvId, steeringTimeouts, cleanupExecution } = deps;
10
+ broadcastSync, processMessageWithStreaming, cleanupExecution, activeProcessesByConvId, steeringTimeouts } = deps;
11
11
 
12
12
  // Per-conversation queue seq counter for event ordering
13
13
  const queueSeqByConv = new Map();
@@ -87,8 +87,10 @@ export function register(router, deps) {
87
87
  router.handle('conv.chunks', (p) => {
88
88
  if (!queries.getConversation(p.id)) notFound('Conversation not found');
89
89
  const since = parseInt(p.since || '0');
90
- const allChunks = queries.getConversationChunks(p.id);
91
- return { ok: true, chunks: since > 0 ? allChunks.filter(c => c.created_at > since) : allChunks };
90
+ const chunks = since > 0
91
+ ? queries.getConversationChunksSince(p.id, since)
92
+ : queries.getConversationChunks(p.id);
93
+ return { ok: true, chunks };
92
94
  });
93
95
 
94
96
  router.handle('conv.chunks.earlier', (p) => {
@@ -131,53 +133,35 @@ export function register(router, deps) {
131
133
  const conv = queries.getConversation(p.id);
132
134
  if (!conv) notFound('Conversation not found');
133
135
  if (!p.content) fail(400, 'Missing content');
134
- const entry = activeExecutions.get(p.id);
135
- if (!entry) {
136
- // Allow steering if we still have a process, even if execution entry is gone
137
- const proc = activeProcessesByConvId.get(p.id);
138
- if (!proc || !proc.stdin) fail(409, 'No active execution to steer');
139
- }
140
- const proc = activeProcessesByConvId.get(p.id);
141
- if (!proc || !proc.stdin) fail(409, 'Process not available for steering');
142
136
 
143
- const message = queries.createMessage(p.id, 'user', p.content);
144
- queries.createEvent('message.created', { role: 'user', messageId: message.id }, p.id);
145
- broadcastSync({ type: 'message_created', conversationId: p.id, message, timestamp: Date.now() });
137
+ const entry = activeExecutions.get(p.id);
138
+ const proc = (entry && entry.proc) || activeProcessesByConvId.get(p.id);
146
139
 
147
- try {
148
- // Send steering prompt using JSON-RPC format so agent processes it immediately
149
- // Get the active session ID from the entry
150
- const sessionId = entry?.sessionId || queries.getConversation(p.id)?.sessionId;
140
+ if (proc && proc.stdin && !proc.stdin.destroyed) {
141
+ // Agent is running and stdin is alive inject prompt directly
142
+ const message = queries.createMessage(p.id, 'user', p.content);
143
+ queries.createEvent('message.created', { role: 'user', messageId: message.id }, p.id);
144
+ broadcastSync({ type: 'message_created', conversationId: p.id, message, timestamp: Date.now() });
151
145
 
152
- // Send JSON-RPC prompt request with requestId for tracking
146
+ const sessionId = entry?.sessionId;
153
147
  const promptRequest = {
154
148
  jsonrpc: '2.0',
155
- id: Date.now(), // Use timestamp as unique request ID
149
+ id: Date.now(),
156
150
  method: 'session/prompt',
157
- params: {
158
- sessionId,
159
- prompt: [{ type: 'text', text: p.content }]
160
- }
151
+ params: { sessionId, prompt: [{ type: 'text', text: p.content }] }
161
152
  };
162
-
163
153
  proc.stdin.write(JSON.stringify(promptRequest) + '\n');
164
154
 
165
- // Clear the steering cleanup timeout so process stays alive longer
166
- const timeout = steeringTimeouts.get(p.id);
167
- if (timeout) {
168
- clearTimeout(timeout);
169
- // Set a new timeout (another 30 seconds for follow-up steers)
170
- const newTimeout = setTimeout(() => {
171
- activeProcessesByConvId.delete(p.id);
172
- steeringTimeouts.delete(p.id);
173
- }, 30000);
174
- steeringTimeouts.set(p.id, newTimeout);
175
- }
155
+ // Reset steering timeout
156
+ const existing = steeringTimeouts.get(p.id);
157
+ if (existing) clearTimeout(existing);
158
+ const t = setTimeout(() => { activeProcessesByConvId.delete(p.id); steeringTimeouts.delete(p.id); }, 30000);
159
+ steeringTimeouts.set(p.id, t);
176
160
 
177
161
  return { ok: true, steered: true, conversationId: p.id, messageId: message.id };
178
- } catch (err) {
179
- fail(500, `Failed to steer: ${err.message}`);
180
162
  }
163
+
164
+ fail(409, 'Process not available for steering');
181
165
  });
182
166
 
183
167
  router.handle('msg.ls', (p) => {
@@ -1,8 +1,8 @@
1
1
  import { encode } from './codec.js';
2
2
 
3
3
  const MESSAGE_PRIORITY = {
4
- high: ['streaming_error', 'streaming_complete', 'rate_limit_hit', 'streaming_cancelled', 'run_cancelled', 'tool_install_complete', 'tool_update_complete', 'tool_install_failed', 'tool_update_failed'],
5
- normal: ['streaming_progress', 'streaming_start', 'message_created', 'queue_status', 'tool_install_progress', 'tool_update_progress'],
4
+ high: ['streaming_error', 'streaming_complete', 'rate_limit_hit', 'streaming_cancelled', 'run_cancelled', 'tool_install_complete', 'tool_update_complete', 'tool_install_failed', 'tool_update_failed', 'streaming_start', 'message_created'],
5
+ normal: ['streaming_progress', 'queue_status', 'tool_install_progress', 'tool_update_progress'],
6
6
  low: ['model_download_progress', 'stt_progress', 'tts_setup_progress', 'voice_list', 'tts_audio']
7
7
  };
8
8
 
@@ -33,7 +33,6 @@ class ClientQueue {
33
33
  this.normalPriority = [];
34
34
  this.lowPriority = [];
35
35
  this.timer = null;
36
- this.lastKey = null;
37
36
  this.messageCount = 0;
38
37
  this.bytesSent = 0;
39
38
  this.windowStart = Date.now();
@@ -41,10 +40,6 @@ class ClientQueue {
41
40
  }
42
41
 
43
42
  add(event, priority) {
44
- // Deduplicate by type+seq key
45
- const key = event.type + (event.seq ?? '') + (event.sessionId ?? '');
46
- if (this.lastKey === key) return;
47
- this.lastKey = key;
48
43
  if (priority === 3) this.highPriority.push(event);
49
44
  else if (priority === 2) this.normalPriority.push(event);
50
45
  else this.lowPriority.push(event);
@@ -72,7 +67,7 @@ class ClientQueue {
72
67
  this.windowStart = now;
73
68
  this.rateLimitWarned = false;
74
69
  }
75
- const batch = [...this.highPriority.splice(0), ...this.normalPriority.splice(0), ...this.lowPriority.splice(0, 5)];
70
+ const batch = [...this.highPriority.splice(0), ...this.normalPriority.splice(0), ...this.lowPriority.splice(0)];
76
71
  if (batch.length === 0) return;
77
72
  const messagesThisSecond = this.messageCount + batch.length;
78
73
  if (messagesThisSecond > 100) {
@@ -81,8 +76,14 @@ class ClientQueue {
81
76
  this.rateLimitWarned = true;
82
77
  }
83
78
  const allowedCount = 100 - this.messageCount;
84
- if (allowedCount <= 0) { this.scheduleFlush(); return; }
85
- batch.splice(allowedCount);
79
+ if (allowedCount <= 0) {
80
+ // Put everything back and reschedule — don't drop events
81
+ this.normalPriority.unshift(...batch);
82
+ this.scheduleFlush();
83
+ return;
84
+ }
85
+ // Return overflow to front of queue for next flush cycle
86
+ this.normalPriority.unshift(...batch.splice(allowedCount));
86
87
  }
87
88
  const envelope = batch.length === 1 ? batch[0] : batch;
88
89
  const binary = encode(envelope);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.675",
3
+ "version": "1.0.677",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "server.js",
@@ -31,9 +31,8 @@
31
31
  "busboy": "^1.6.0",
32
32
  "express": "^5.2.1",
33
33
  "form-data": "^4.0.5",
34
- "fsbrowse": "^0.2.18",
34
+ "fsbrowse": "latest",
35
35
  "google-auth-library": "^10.5.0",
36
- "gpt-tokenizer": "^3.4.0",
37
36
  "msgpackr": "^1.11.8",
38
37
  "onnxruntime-node": "1.21.0",
39
38
  "opencode-ai": "^1.2.15",
@@ -166,17 +166,30 @@ if (fs.existsSync(fsbrowseAppJSPath)) {
166
166
  try {
167
167
  let appContent = fs.readFileSync(fsbrowseAppJSPath, 'utf8');
168
168
 
169
- // Check if theme sync is already patched
170
- if (appContent.includes('setupThemeSync')) {
171
- console.log('[PATCH] fsbrowse theme sync already patched');
169
+ // Ensure postMessage theme listener is present (storage events don't fire in same-window iframes)
170
+ if (appContent.includes('theme-change')) {
171
+ console.log('[PATCH] fsbrowse postMessage theme sync already present');
172
+ } else if (appContent.includes('setupThemeSync')) {
173
+ // setupThemeSync exists but lacks postMessage support - inject it
174
+ appContent = appContent.replace(
175
+ " // Watch for storage changes from other tabs/windows\n window.addEventListener('storage', e => {\n if (e.key === 'gmgui-theme') syncTheme();\n });",
176
+ " // Watch for storage changes from other tabs/windows\n window.addEventListener('storage', e => {\n if (e.key === 'gmgui-theme') syncTheme();\n });\n\n // Watch for postMessage from parent window (same-window iframes don't receive storage events)\n window.addEventListener('message', e => {\n if (e.data && e.data.type === 'theme-change' && e.data.theme) syncTheme(e.data.theme);\n });"
177
+ );
178
+ // Also make syncTheme accept an explicit theme argument
179
+ appContent = appContent.replace(
180
+ 'const syncTheme = () => {\n const theme = localStorage.getItem(\'gmgui-theme\') ||',
181
+ 'const syncTheme = (theme) => {\n theme = theme || localStorage.getItem(\'gmgui-theme\') ||'
182
+ );
183
+ fs.writeFileSync(fsbrowseAppJSPath, appContent, 'utf8');
184
+ console.log('[PATCH] fsbrowse postMessage theme sync injected');
172
185
  } else {
173
- // Inject setupThemeSync call and method
186
+ // No setupThemeSync at all - inject full method
174
187
  const themeSyncMethod = `
175
188
  setupThemeSync() {
176
189
  // 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');
190
+ const syncTheme = (theme) => {
191
+ theme = theme || localStorage.getItem('gmgui-theme') ||
192
+ (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
180
193
  document.documentElement.className = theme;
181
194
  document.documentElement.setAttribute('data-theme', theme);
182
195
  };
@@ -188,23 +201,19 @@ if (fs.existsSync(fsbrowseAppJSPath)) {
188
201
  if (e.key === 'gmgui-theme') syncTheme();
189
202
  });
190
203
 
204
+ // Watch for postMessage from parent window (same-window iframes don't receive storage events)
205
+ window.addEventListener('message', e => {
206
+ if (e.data && e.data.type === 'theme-change' && e.data.theme) syncTheme(e.data.theme);
207
+ });
208
+
191
209
  // Watch for media query changes
192
210
  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', syncTheme);
193
211
  },`;
194
212
 
195
- // Add setupThemeSync call to init()
196
213
  appContent = appContent.replace(
197
214
  'async init() {',
198
215
  'async init() {\n this.setupThemeSync();'
199
216
  );
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
217
  appContent = appContent.replace(
209
218
  'api(path) {\n return `${this.basePath}${path}`;\n },',
210
219
  'api(path) {\n return `${this.basePath}${path}`;\n },' + themeSyncMethod
package/server.js CHANGED
@@ -302,7 +302,7 @@ const activeProcessesByRunId = new Map();
302
302
  const activeProcessesByConvId = new Map(); // Store process handles by conversationId for steering
303
303
  const steeringTimeouts = new Map(); // Track timeout handles for process cleanup
304
304
  const checkpointManager = new CheckpointManager(queries);
305
- const STUCK_AGENT_THRESHOLD_MS = 600000;
305
+ const STUCK_AGENT_THRESHOLD_MS = 1800000;
306
306
  const NO_PID_GRACE_PERIOD_MS = 60000;
307
307
  const DEFAULT_RATE_LIMIT_COOLDOWN_MS = 60000;
308
308
 
@@ -1106,11 +1106,7 @@ function compressAndSend(req, res, statusCode, contentType, body) {
1106
1106
  res.end(raw);
1107
1107
  return;
1108
1108
  }
1109
- if (acceptsEncoding(req, 'br')) {
1110
- const compressed = zlib.brotliCompressSync(raw, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } });
1111
- res.writeHead(statusCode, { ...baseHeaders, 'Content-Encoding': 'br', 'Content-Length': compressed.length });
1112
- res.end(compressed);
1113
- } else if (acceptsEncoding(req, 'gzip')) {
1109
+ if (acceptsEncoding(req, 'gzip')) {
1114
1110
  const compressed = zlib.gzipSync(raw, { level: 6 });
1115
1111
  res.writeHead(statusCode, { ...baseHeaders, 'Content-Encoding': 'gzip', 'Content-Length': compressed.length });
1116
1112
  res.end(compressed);
@@ -1176,14 +1172,11 @@ const server = http.createServer(async (req, res) => {
1176
1172
 
1177
1173
  if (pathOnly === '/api/conversations' && req.method === 'GET') {
1178
1174
  const conversations = queries.getConversationsList();
1179
- // Filter out stale streaming state: check both activeExecutions AND database active sessions
1175
+ // Filter out stale streaming state using a single bulk query instead of N+1 per-conversation queries
1176
+ const activeSessionConvIds = new Set(queries.getActiveSessionConversationIds());
1180
1177
  for (const conv of conversations) {
1181
- if (conv.isStreaming) {
1182
- const hasActiveSession = queries.getSessionsByStatus(conv.id, 'active').length > 0 ||
1183
- queries.getSessionsByStatus(conv.id, 'pending').length > 0;
1184
- if (!activeExecutions.has(conv.id) && !hasActiveSession) {
1185
- conv.isStreaming = 0;
1186
- }
1178
+ if (conv.isStreaming && !activeExecutions.has(conv.id) && !activeSessionConvIds.has(conv.id)) {
1179
+ conv.isStreaming = 0;
1187
1180
  }
1188
1181
  }
1189
1182
  sendJSON(req, res, 200, { conversations });
@@ -3259,6 +3252,12 @@ function generateETag(stats) {
3259
3252
  return `"${stats.mtimeMs.toString(36)}-${stats.size.toString(36)}"`;
3260
3253
  }
3261
3254
 
3255
+ // In-memory cache: etag -> { br: Buffer, gz: Buffer, raw: Buffer }
3256
+ const _assetCache = new Map();
3257
+ // Cached processed HTML (invalidated on hot-reload or server restart)
3258
+ let _htmlCache = null;
3259
+ let _htmlCacheEtag = null;
3260
+
3262
3261
  function serveFile(filePath, res, req) {
3263
3262
  const ext = path.extname(filePath).toLowerCase();
3264
3263
  const contentType = MIME_TYPES[ext] || 'application/octet-stream';
@@ -3272,43 +3271,68 @@ function serveFile(filePath, res, req) {
3272
3271
  res.end();
3273
3272
  return;
3274
3273
  }
3275
- const headers = {
3276
- 'Content-Type': contentType,
3277
- 'Content-Length': stats.size,
3278
- 'ETag': etag,
3279
- 'Cache-Control': ['.js', '.css'].includes(ext) ? 'public, max-age=0, must-revalidate' : 'public, max-age=3600, must-revalidate'
3274
+ const isJsCss = ['.js', '.css'].includes(ext);
3275
+ // Use long cache + ETag for immutable assets; browser only re-requests on server restart
3276
+ const cacheControl = isJsCss
3277
+ ? 'public, max-age=31536000, immutable'
3278
+ : 'public, max-age=3600, must-revalidate';
3279
+
3280
+ const sendCached = (cached) => {
3281
+ if (acceptsEncoding(req, 'gzip') && cached.gz) {
3282
+ res.writeHead(200, { 'Content-Type': contentType, 'Content-Encoding': 'gzip', 'Content-Length': cached.gz.length, 'ETag': etag, 'Cache-Control': cacheControl });
3283
+ res.end(cached.gz);
3284
+ } else {
3285
+ res.writeHead(200, { 'Content-Type': contentType, 'Content-Length': cached.raw.length, 'ETag': etag, 'Cache-Control': cacheControl });
3286
+ res.end(cached.raw);
3287
+ }
3280
3288
  };
3281
- if (acceptsEncoding(req, 'br') && stats.size > 860) {
3282
- const stream = fs.createReadStream(filePath);
3283
- headers['Content-Encoding'] = 'br';
3284
- delete headers['Content-Length'];
3285
- res.writeHead(200, headers);
3286
- stream.pipe(zlib.createBrotliCompress({ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } })).pipe(res);
3287
- } else if (acceptsEncoding(req, 'gzip') && stats.size > 860) {
3288
- const stream = fs.createReadStream(filePath);
3289
- headers['Content-Encoding'] = 'gzip';
3290
- delete headers['Content-Length'];
3291
- res.writeHead(200, headers);
3292
- stream.pipe(zlib.createGzip({ level: 6 })).pipe(res);
3293
- } else {
3294
- res.writeHead(200, headers);
3295
- fs.createReadStream(filePath).pipe(res);
3296
- }
3289
+
3290
+ const cached = _assetCache.get(etag);
3291
+ if (cached) { sendCached(cached); return; }
3292
+
3293
+ fs.readFile(filePath, (err2, raw) => {
3294
+ if (err2) { res.writeHead(500); res.end('Server error'); return; }
3295
+ if (raw.length < 860) {
3296
+ const entry = { raw, gz: null };
3297
+ _assetCache.set(etag, entry);
3298
+ sendCached(entry);
3299
+ return;
3300
+ }
3301
+ // Pre-compress once with gzip, cache it
3302
+ const gz = zlib.gzipSync(raw, { level: 6 });
3303
+ const entry = { raw, gz };
3304
+ _assetCache.set(etag, entry);
3305
+ sendCached(entry);
3306
+ });
3297
3307
  });
3298
3308
  return;
3299
3309
  }
3300
3310
 
3301
- fs.readFile(filePath, (err, data) => {
3311
+ // HTML: cache processed result, invalidate when file changes
3312
+ fs.stat(filePath, (err, stats) => {
3302
3313
  if (err) { res.writeHead(500); res.end('Server error'); return; }
3303
- let content = data.toString();
3304
- const baseTag = `<script>window.__BASE_URL='${BASE_URL}';window.__SERVER_VERSION='${PKG_VERSION}';</script>`;
3305
- content = content.replace('<head>', `<head>\n <base href="${BASE_URL}/">\n ` + baseTag);
3306
- content = content.replace(/(href|src)="vendor\//g, `$1="${BASE_URL}/vendor/`);
3307
- content = content.replace(/(src)="\/gm\/js\//g, `$1="${BASE_URL}/js/`);
3308
- if (watch) {
3309
- 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>`;
3310
- }
3311
- compressAndSend(req, res, 200, contentType, content);
3314
+ const etag = generateETag(stats);
3315
+ if (!watch && _htmlCache && _htmlCacheEtag === etag) {
3316
+ res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-store', 'Content-Encoding': 'gzip', 'Content-Length': _htmlCache.length });
3317
+ res.end(_htmlCache);
3318
+ return;
3319
+ }
3320
+ fs.readFile(filePath, (err2, data) => {
3321
+ if (err2) { res.writeHead(500); res.end('Server error'); return; }
3322
+ let content = data.toString();
3323
+ const baseTag = `<script>window.__BASE_URL='${BASE_URL}';window.__SERVER_VERSION='${PKG_VERSION}';</script>`;
3324
+ content = content.replace('<head>', `<head>\n <base href="${BASE_URL}/">\n ` + baseTag);
3325
+ content = content.replace(/(href|src)="vendor\//g, `$1="${BASE_URL}/vendor/`);
3326
+ content = content.replace(/(src)="\/gm\/js\//g, `$1="${BASE_URL}/js/`);
3327
+ if (watch) {
3328
+ 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>`;
3329
+ }
3330
+ compressAndSend(req, res, 200, contentType, content);
3331
+ if (!watch && acceptsEncoding(req, 'gzip')) {
3332
+ _htmlCache = zlib.gzipSync(Buffer.from(content), { level: 6 });
3333
+ _htmlCacheEtag = etag;
3334
+ }
3335
+ });
3312
3336
  });
3313
3337
  }
3314
3338
 
@@ -3426,8 +3450,8 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
3426
3450
  if (parsed.type === 'system') {
3427
3451
  if (parsed.subtype === 'task_notification') return;
3428
3452
 
3429
- if (parsed.session_id) {
3430
- queries.setClaudeSessionId(conversationId, parsed.session_id);
3453
+ if (parsed.session_id && parsed.session_id !== resumeSessionId) {
3454
+ queries.setClaudeSessionId(conversationId, parsed.session_id, sessionId);
3431
3455
  debugLog(`[stream] Eagerly persisted claudeSessionId=${parsed.session_id} for conv=${conversationId}`);
3432
3456
  }
3433
3457
 
@@ -3700,8 +3724,10 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
3700
3724
  if (entry) entry.pid = pid;
3701
3725
  },
3702
3726
  onProcess: (proc) => {
3703
- // Store process handle for steering
3727
+ // Store process handle for steering - both maps so steer handler always finds it
3704
3728
  activeProcessesByConvId.set(conversationId, proc);
3729
+ const entry = activeExecutions.get(conversationId);
3730
+ if (entry) entry.proc = proc;
3705
3731
  }
3706
3732
  };
3707
3733
 
@@ -3853,6 +3879,8 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
3853
3879
  return;
3854
3880
  }
3855
3881
 
3882
+ const isSessionConflict = error.exitCode === null && eventCount === 0;
3883
+
3856
3884
  broadcastSync({
3857
3885
  type: 'streaming_error',
3858
3886
  sessionId,
@@ -3862,16 +3890,19 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
3862
3890
  exitCode: error.exitCode,
3863
3891
  stderrText: error.stderrText,
3864
3892
  recoverable: elapsed < 60000,
3893
+ isSessionConflict,
3865
3894
  timestamp: Date.now()
3866
3895
  });
3867
3896
 
3868
- const errorMessage = queries.createMessage(conversationId, 'assistant', `Error: ${error.message}`);
3869
- broadcastSync({
3870
- type: 'message_created',
3871
- conversationId,
3872
- message: errorMessage,
3873
- timestamp: Date.now()
3874
- });
3897
+ if (!isSessionConflict) {
3898
+ const errorMessage = queries.createMessage(conversationId, 'assistant', `Error: ${error.message}`);
3899
+ broadcastSync({
3900
+ type: 'message_created',
3901
+ conversationId,
3902
+ message: errorMessage,
3903
+ timestamp: Date.now()
3904
+ });
3905
+ }
3875
3906
  } finally {
3876
3907
  batcher.drain();
3877
3908
  // Use atomic cleanup but only if not in rate limit recovery
@@ -3910,6 +3941,7 @@ function scheduleRetry(conversationId, messageId, content, agentId, model, subAg
3910
3941
  conversationId,
3911
3942
  messageId,
3912
3943
  agentId,
3944
+ queueLength: messageQueues.get(conversationId)?.length || 0,
3913
3945
  timestamp: Date.now()
3914
3946
  });
3915
3947
 
@@ -3961,6 +3993,7 @@ function drainMessageQueue(conversationId) {
3961
3993
  conversationId,
3962
3994
  messageId: next.messageId,
3963
3995
  agentId: next.agentId,
3996
+ queueLength: queue?.length || 0,
3964
3997
  timestamp: Date.now()
3965
3998
  });
3966
3999
 
@@ -3995,10 +4028,10 @@ function drainMessageQueue(conversationId) {
3995
4028
 
3996
4029
  const wss = new WebSocketServer({
3997
4030
  server,
3998
- perMessageDeflate: {
3999
- zlibDeflateOptions: { level: 6 },
4000
- threshold: 256
4001
- }
4031
+ perMessageDeflate: false // Disabled: msgpack binary doesn't compress well, and
4032
+ // synchronous zlib on every frame blocks the event loop.
4033
+ // HTTP-layer gzip already handles static assets; WS
4034
+ // streaming events are small and latency-sensitive.
4002
4035
  });
4003
4036
  wss.on('error', (err) => {
4004
4037
  console.error('[WSS] WebSocket server error (contained):', err.message);
@@ -4071,6 +4104,11 @@ const wsOptimizer = new WSOptimizer();
4071
4104
 
4072
4105
  function broadcastSync(event) {
4073
4106
  try {
4107
+ // Assign global sequence number to ALL events for ordering guarantee
4108
+ if (!event.seq) {
4109
+ event.seq = ++broadcastSeq;
4110
+ }
4111
+
4074
4112
  const isBroadcast = BROADCAST_TYPES.has(event.type);
4075
4113
 
4076
4114
  if (syncClients.size > 0) {
@@ -4152,12 +4190,15 @@ wsRouter.onLegacy((data, ws) => {
4152
4190
  if (data.conversationId && activeExecutions.has(data.conversationId)) {
4153
4191
  const execution = activeExecutions.get(data.conversationId);
4154
4192
  const conv = queries.getConversation(data.conversationId);
4193
+ const queue = messageQueues.get(data.conversationId);
4155
4194
  sendWs(ws, ({
4156
4195
  type: 'streaming_start',
4157
4196
  sessionId: execution.sessionId,
4158
4197
  conversationId: data.conversationId,
4159
4198
  agentId: conv?.agentType || conv?.agentId || 'claude-code',
4199
+ queueLength: queue?.length || 0,
4160
4200
  resumed: true,
4201
+ seq: ++broadcastSeq,
4161
4202
  timestamp: Date.now()
4162
4203
  }));
4163
4204
  }
@@ -4382,7 +4423,12 @@ if (watch) {
4382
4423
  const fp = path.join(staticDir, file);
4383
4424
  if (watchedFiles.has(fp)) return;
4384
4425
  fs.watchFile(fp, { interval: 100 }, (curr, prev) => {
4385
- if (curr.mtime > prev.mtime) hotReloadClients.forEach(c => { if (c.readyState === 1) c.send(JSON.stringify({ type: 'reload' })); });
4426
+ if (curr.mtime > prev.mtime) {
4427
+ _assetCache.clear();
4428
+ _htmlCache = null;
4429
+ _htmlCacheEtag = null;
4430
+ hotReloadClients.forEach(c => { if (c.readyState === 1) c.send(JSON.stringify({ type: 'reload' })); });
4431
+ }
4386
4432
  });
4387
4433
  watchedFiles.set(fp, true);
4388
4434
  });
@@ -4569,7 +4615,7 @@ function performAgentHealthCheck() {
4569
4615
  debugLog(`[HEALTH] Agent PID ${entry.pid} for conv ${conversationId} has no activity for ${Math.round((now - entry.lastActivity) / 1000)}s`);
4570
4616
  // Kill stuck agent and clear streaming state
4571
4617
  try { process.kill(entry.pid, 'SIGTERM'); } catch (e) {}
4572
- markAgentDead(conversationId, entry, 'Agent was stuck (no activity for 10 minutes)');
4618
+ markAgentDead(conversationId, entry, 'Agent was stuck (no activity for 30 minutes)');
4573
4619
  }
4574
4620
  } else {
4575
4621
  if (now - entry.startTime > NO_PID_GRACE_PERIOD_MS) {
package/static/index.html CHANGED
@@ -840,6 +840,18 @@
840
840
  background-color: var(--color-bg-secondary);
841
841
  }
842
842
 
843
+ .agent-selector[data-streaming="true"], .model-selector[data-streaming="true"] {
844
+ opacity: 0.6;
845
+ cursor: not-allowed;
846
+ background-color: var(--color-bg-secondary);
847
+ }
848
+
849
+ .message-textarea[data-streaming="true"] {
850
+ opacity: 0.7;
851
+ cursor: not-allowed;
852
+ background-color: var(--color-bg-secondary);
853
+ }
854
+
843
855
  .model-selector {
844
856
  padding: 0.5rem;
845
857
  border: none;
@@ -910,6 +922,29 @@
910
922
 
911
923
  .send-btn:hover:not(:disabled) { background-color: var(--color-primary-dark); }
912
924
  .send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
925
+ .send-btn[data-streaming="true"] { opacity: 0.5; cursor: not-allowed; background-color: var(--color-primary); }
926
+
927
+ /* ===== Data-streaming state styling for all prompt controls ===== */
928
+ .input-section[data-streaming="true"] {
929
+ opacity: 0.85;
930
+ }
931
+
932
+ .input-section[data-streaming="true"] .message-textarea {
933
+ opacity: 0.7;
934
+ cursor: not-allowed;
935
+ background-color: var(--color-bg-secondary);
936
+ }
937
+
938
+ .input-section[data-streaming="true"] .agent-selector,
939
+ .input-section[data-streaming="true"] .model-selector {
940
+ opacity: 0.6;
941
+ cursor: not-allowed;
942
+ }
943
+
944
+ .input-section[data-streaming="true"] .send-btn {
945
+ opacity: 0.5;
946
+ cursor: not-allowed;
947
+ }
913
948
 
914
949
  .send-btn svg { width: 18px; height: 18px; }
915
950
 
@@ -3249,7 +3284,7 @@
3249
3284
  <script defer src="/gm/js/event-processor.js"></script>
3250
3285
  <script defer src="/gm/js/streaming-renderer.js"></script>
3251
3286
  <script defer src="/gm/js/image-loader.js"></script>
3252
- <script src="/gm/lib/msgpackr.min.js"></script>
3287
+ <script defer src="/gm/lib/msgpackr.min.js"></script>
3253
3288
  <script defer src="/gm/js/websocket-manager.js"></script>
3254
3289
  <script defer src="/gm/js/ws-client.js"></script>
3255
3290
  <script defer src="/gm/js/syntax-highlighter.js"></script>