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.
- package/CLAUDE.md +119 -218
- package/database.js +55 -10
- package/docs/index.html +1 -1
- package/lib/claude-runner.js +5 -4
- package/lib/codec.js +2 -51
- package/lib/tool-manager.js +5 -3
- package/lib/ws-handlers-conv.js +22 -38
- package/lib/ws-optimizer.js +11 -10
- package/package.json +2 -3
- package/scripts/patch-fsbrowse.js +25 -16
- package/server.js +106 -60
- package/static/index.html +36 -1
- package/static/js/client.js +58 -25
- package/static/js/conversations.js +26 -2
- package/static/js/terminal.js +2 -2
- package/static/js/websocket-manager.js +5 -22
- package/static/theme.js +6 -0
- package/test-state-management.mjs +269 -0
- package/test-thread-steering.mjs +100 -0
package/lib/tool-manager.js
CHANGED
|
@@ -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
|
-
|
|
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]}`);
|
package/lib/ws-handlers-conv.js
CHANGED
|
@@ -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
|
|
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
|
|
91
|
-
|
|
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
|
|
144
|
-
|
|
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
|
-
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
146
|
+
const sessionId = entry?.sessionId;
|
|
153
147
|
const promptRequest = {
|
|
154
148
|
jsonrpc: '2.0',
|
|
155
|
-
id: Date.now(),
|
|
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
|
-
//
|
|
166
|
-
const
|
|
167
|
-
if (
|
|
168
|
-
|
|
169
|
-
|
|
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) => {
|
package/lib/ws-optimizer.js
CHANGED
|
@@ -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', '
|
|
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
|
|
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) {
|
|
85
|
-
|
|
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.
|
|
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": "
|
|
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
|
-
//
|
|
170
|
-
if (appContent.includes('
|
|
171
|
-
console.log('[PATCH] fsbrowse theme sync already
|
|
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
|
-
//
|
|
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
|
-
|
|
179
|
-
|
|
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 =
|
|
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, '
|
|
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
|
|
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
|
-
|
|
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
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
'
|
|
3279
|
-
|
|
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
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
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
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
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
|
-
|
|
4000
|
-
|
|
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)
|
|
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
|
|
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>
|