agentgui 1.0.32 → 1.0.34
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/DIAGNOSTICS.md +61 -0
- package/IMPLEMENTATION_CHECKLIST.md +287 -0
- package/STATE_MACHINE_SUMMARY.md +172 -0
- package/database.js +43 -32
- package/package.json +1 -1
- package/server.js +223 -70
- package/state-manager.js +360 -0
- package/test-state-manager.js +55 -0
package/server.js
CHANGED
|
@@ -9,6 +9,13 @@ import { queries } from './database.js';
|
|
|
9
9
|
import ACPConnection from './acp-launcher.js';
|
|
10
10
|
import { ResponseFormatter } from './response-formatter.js';
|
|
11
11
|
import { HTMLWrapper } from './html-wrapper.js';
|
|
12
|
+
import { SessionStateStore } from './state-manager.js';
|
|
13
|
+
|
|
14
|
+
// Debug logging to file
|
|
15
|
+
const debugLog = (msg) => {
|
|
16
|
+
const timestamp = new Date().toISOString();
|
|
17
|
+
console.error(`[${timestamp}] ${msg}`);
|
|
18
|
+
};
|
|
12
19
|
|
|
13
20
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
21
|
const PORT = process.env.PORT || 3000;
|
|
@@ -21,26 +28,62 @@ if (!fs.existsSync(staticDir)) fs.mkdirSync(staticDir, { recursive: true });
|
|
|
21
28
|
// ACP connection pool keyed by agentId
|
|
22
29
|
const acpPool = new Map();
|
|
23
30
|
|
|
31
|
+
// Global session state store - tracks ALL prompt processing with explicit states
|
|
32
|
+
const sessionStateStore = new SessionStateStore();
|
|
33
|
+
|
|
34
|
+
// Periodic cleanup of old sessions
|
|
35
|
+
setInterval(() => {
|
|
36
|
+
sessionStateStore.cleanup(3600000); // Clean sessions older than 1 hour
|
|
37
|
+
}, 600000); // Run every 10 minutes
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get or create ACP connection with timeout protection
|
|
41
|
+
*/
|
|
24
42
|
async function getACP(agentId, cwd) {
|
|
25
43
|
let conn = acpPool.get(agentId);
|
|
26
|
-
if (conn?.isRunning())
|
|
44
|
+
if (conn?.isRunning()) {
|
|
45
|
+
console.log(`[getACP] Returning cached connection for ${agentId}`);
|
|
46
|
+
return conn;
|
|
47
|
+
}
|
|
27
48
|
|
|
49
|
+
console.log(`[getACP] Creating new ACP connection for ${agentId}`);
|
|
28
50
|
conn = new ACPConnection();
|
|
29
51
|
const agentType = agentId === 'opencode' ? 'opencode' : 'claude-code';
|
|
30
52
|
|
|
53
|
+
// Wrap entire init in timeout to prevent indefinite hangs
|
|
54
|
+
return Promise.race([
|
|
55
|
+
initializeACP(conn, agentType, cwd, agentId),
|
|
56
|
+
new Promise((_, reject) =>
|
|
57
|
+
setTimeout(() => reject(new Error('ACP initialization timeout (>60s)')), 60000)
|
|
58
|
+
)
|
|
59
|
+
]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Initialize ACP with all steps
|
|
64
|
+
*/
|
|
65
|
+
async function initializeACP(conn, agentType, cwd, agentId) {
|
|
31
66
|
try {
|
|
67
|
+
console.log(`[getACP] Step 1: Connecting to ${agentType}...`);
|
|
32
68
|
await conn.connect(agentType, cwd);
|
|
69
|
+
console.log(`[getACP] Step 2: Connected, initializing...`);
|
|
33
70
|
await conn.initialize();
|
|
71
|
+
console.log(`[getACP] Step 3: Initialized, creating session...`);
|
|
34
72
|
await conn.newSession(cwd);
|
|
73
|
+
console.log(`[getACP] Step 4: Session created, setting mode...`);
|
|
35
74
|
await conn.setSessionMode('bypassPermissions');
|
|
75
|
+
console.log(`[getACP] Step 5: Injecting skills...`);
|
|
36
76
|
// Inject system prompt to ensure HTML/RippleUI formatting
|
|
37
77
|
await conn.injectSkills();
|
|
78
|
+
console.log(`[getACP] Step 6: Injecting system context...`);
|
|
38
79
|
await conn.injectSystemContext();
|
|
80
|
+
console.log(`[getACP] Step 7: All initialization complete, caching connection`);
|
|
39
81
|
acpPool.set(agentId, conn);
|
|
40
|
-
console.log(`ACP connection ready for ${agentId} in ${cwd}`);
|
|
82
|
+
console.log(`[getACP] ✅ ACP connection ready for ${agentId} in ${cwd}`);
|
|
41
83
|
return conn;
|
|
42
84
|
} catch (err) {
|
|
43
|
-
console.error(`Failed to initialize ACP connection for ${agentId}: ${err.message}`);
|
|
85
|
+
console.error(`[getACP] ❌ ERROR: Failed to initialize ACP connection for ${agentId}: ${err.message}`);
|
|
86
|
+
console.error(`[getACP] Stack: ${err.stack}`);
|
|
44
87
|
acpPool.delete(agentId);
|
|
45
88
|
if (conn) await conn.terminate();
|
|
46
89
|
throw new Error(`ACP initialization failed for ${agentId}: ${err.message}`);
|
|
@@ -154,10 +197,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
154
197
|
broadcastSync({ type: 'message_created', conversationId, message });
|
|
155
198
|
const session = queries.createSession(conversationId);
|
|
156
199
|
queries.createEvent('session.created', { messageId: message.id, sessionId: session.id }, conversationId, session.id);
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
200
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
201
|
+
res.end(JSON.stringify({ message, session, idempotencyKey }));
|
|
202
|
+
// Fire-and-forget with proper error handling
|
|
203
|
+
processMessage(conversationId, message.id, session.id, body.content, body.agentId, body.folderContext)
|
|
204
|
+
.catch(err => debugLog(`[processMessage] Uncaught error: ${err.message}`));
|
|
205
|
+
return;
|
|
161
206
|
}
|
|
162
207
|
}
|
|
163
208
|
|
|
@@ -200,6 +245,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
200
245
|
return;
|
|
201
246
|
}
|
|
202
247
|
|
|
248
|
+
// Diagnostics endpoint - shows ALL active and recent sessions
|
|
249
|
+
if (routePath === '/api/diagnostics/sessions' && req.method === 'GET') {
|
|
250
|
+
const diagnostics = sessionStateStore.getDiagnostics();
|
|
251
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
252
|
+
res.end(JSON.stringify(diagnostics, null, 2));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
203
256
|
if (routePath === '/api/import/claude-code' && req.method === 'GET') {
|
|
204
257
|
const result = queries.importClaudeCodeConversations();
|
|
205
258
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -306,77 +359,177 @@ function serveFile(filePath, res) {
|
|
|
306
359
|
});
|
|
307
360
|
}
|
|
308
361
|
|
|
362
|
+
/**
|
|
363
|
+
* Process a user message through the Claude Code ACP with explicit state tracking
|
|
364
|
+
* This is now fully predictable with no hidden failures
|
|
365
|
+
*/
|
|
309
366
|
async function processMessage(conversationId, messageId, sessionId, content, agentId, folderContext) {
|
|
367
|
+
// Create state manager for this session
|
|
368
|
+
const stateManager = sessionStateStore.create(sessionId, conversationId, messageId, 120000);
|
|
369
|
+
|
|
310
370
|
try {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
371
|
+
console.log(`[processMessage] Starting: conversationId=${conversationId}, sessionId=${sessionId}`);
|
|
372
|
+
console.log(`[processMessage] Initial state: ${stateManager.getState()}`);
|
|
373
|
+
|
|
374
|
+
// STATE: PENDING → ACQUIRING_ACP
|
|
375
|
+
stateManager.transition(stateManager.constructor.STATES.ACQUIRING_ACP, {
|
|
376
|
+
reason: 'Connecting to ACP',
|
|
377
|
+
data: {}
|
|
378
|
+
});
|
|
314
379
|
|
|
315
380
|
const cwd = folderContext?.path || '/config';
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
381
|
+
const actualAgentId = agentId || 'claude-code';
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
const conn = await getACP(actualAgentId, cwd);
|
|
385
|
+
|
|
386
|
+
// STATE: ACQUIRING_ACP → ACP_ACQUIRED
|
|
387
|
+
stateManager.transition(stateManager.constructor.STATES.ACP_ACQUIRED, {
|
|
388
|
+
reason: 'ACP connection established',
|
|
389
|
+
data: { acpConnectionTime: Date.now() }
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
let fullText = '';
|
|
393
|
+
const blocks = [];
|
|
394
|
+
const updateChunks = [];
|
|
395
|
+
|
|
396
|
+
// Setup response accumulation
|
|
397
|
+
conn.onUpdate = (params) => {
|
|
398
|
+
const u = params.update;
|
|
399
|
+
if (!u) return;
|
|
400
|
+
const kind = u.sessionUpdate;
|
|
401
|
+
if (kind === 'agent_message_chunk' && u.content?.text) {
|
|
402
|
+
fullText += u.content.text;
|
|
403
|
+
updateChunks.push({ type: 'text', content: u.content.text, timestamp: Date.now() });
|
|
404
|
+
} else if (kind === 'html_content' && u.content?.html) {
|
|
405
|
+
blocks.push({ type: 'html', html: u.content.html, title: u.content.title, id: u.content.id });
|
|
406
|
+
updateChunks.push({ type: 'html', content: u.content.html, title: u.content.title, timestamp: Date.now() });
|
|
407
|
+
} else if (kind === 'image_content' && u.content?.path) {
|
|
408
|
+
const imageUrl = BASE_URL + '/api/image/' + encodeURIComponent(u.content.path);
|
|
409
|
+
blocks.push({ type: 'image', path: u.content.path, url: imageUrl, title: u.content.title, alt: u.content.alt });
|
|
410
|
+
updateChunks.push({ type: 'image', path: u.content.path, url: imageUrl, title: u.content.title, timestamp: Date.now() });
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
// STATE: ACP_ACQUIRED → SENDING_PROMPT
|
|
415
|
+
stateManager.transition(stateManager.constructor.STATES.SENDING_PROMPT, {
|
|
416
|
+
reason: 'Sending prompt to ACP',
|
|
417
|
+
data: {}
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
console.log(`[processMessage] Sending prompt to ACP (${content.length} chars)`);
|
|
421
|
+
const result = await conn.sendPrompt(content);
|
|
422
|
+
conn.onUpdate = null;
|
|
423
|
+
|
|
424
|
+
// STATE: SENDING_PROMPT → PROCESSING
|
|
425
|
+
stateManager.transition(stateManager.constructor.STATES.PROCESSING, {
|
|
426
|
+
reason: 'ACP processing complete, formatting response',
|
|
427
|
+
data: { promptSentTime: Date.now(), responseReceivedTime: Date.now() }
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
console.log(`[processMessage] ACP returned: stopReason=${result?.stopReason}, fullText=${fullText.length} chars`);
|
|
431
|
+
|
|
432
|
+
// Format response
|
|
433
|
+
let responseText = fullText || result?.result || (result?.stopReason ? `Completed: ${result.stopReason}` : 'No response.');
|
|
434
|
+
|
|
435
|
+
// Wrap response in HTML if needed
|
|
436
|
+
const isHTML = responseText.trim().startsWith('<');
|
|
437
|
+
if (!isHTML) {
|
|
438
|
+
responseText = HTMLWrapper.wrapResponse(responseText);
|
|
335
439
|
}
|
|
336
|
-
|
|
440
|
+
|
|
441
|
+
// Segment and format
|
|
442
|
+
const segments = ResponseFormatter.segmentResponse(responseText);
|
|
443
|
+
const metadata = ResponseFormatter.extractMetadata(responseText);
|
|
444
|
+
|
|
445
|
+
const messageContent = blocks.length > 0 ? {
|
|
446
|
+
text: responseText,
|
|
447
|
+
blocks,
|
|
448
|
+
segments,
|
|
449
|
+
metadata,
|
|
450
|
+
updateChunks,
|
|
451
|
+
isHTML: true
|
|
452
|
+
} : {
|
|
453
|
+
text: responseText,
|
|
454
|
+
segments,
|
|
455
|
+
metadata,
|
|
456
|
+
updateChunks,
|
|
457
|
+
isHTML: true
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
// Save response to database
|
|
461
|
+
const assistantMessage = queries.createMessage(conversationId, 'assistant', messageContent);
|
|
462
|
+
queries.updateSession(sessionId, { status: 'completed', response: { text: responseText, messageId: assistantMessage.id }, completed_at: Date.now() });
|
|
463
|
+
queries.createEvent('session.completed', { messageId: assistantMessage.id }, conversationId, sessionId);
|
|
464
|
+
|
|
465
|
+
// Broadcast to connected clients
|
|
466
|
+
broadcastSync({ type: 'session_updated', sessionId, status: 'completed', message: assistantMessage });
|
|
467
|
+
|
|
468
|
+
// STATE: PROCESSING → COMPLETED
|
|
469
|
+
stateManager.transition(stateManager.constructor.STATES.COMPLETED, {
|
|
470
|
+
reason: 'Response successfully generated and saved',
|
|
471
|
+
data: {
|
|
472
|
+
fullText,
|
|
473
|
+
blocks,
|
|
474
|
+
responseLength: responseText.length,
|
|
475
|
+
messageId: assistantMessage.id
|
|
476
|
+
}
|
|
477
|
+
});
|
|
337
478
|
|
|
338
|
-
|
|
339
|
-
|
|
479
|
+
console.log(`[processMessage] ✅ Session completed: ${stateManager.getSummary().duration}`);
|
|
480
|
+
|
|
481
|
+
} catch (acpError) {
|
|
482
|
+
console.error(`[processMessage] ACP Error: ${acpError.message}`);
|
|
483
|
+
console.error(`[processMessage] Stack: ${acpError.stack}`);
|
|
484
|
+
|
|
485
|
+
// STATE: → ERROR
|
|
486
|
+
stateManager.transition(stateManager.constructor.STATES.ERROR, {
|
|
487
|
+
reason: `ACP error: ${acpError.message}`,
|
|
488
|
+
data: {
|
|
489
|
+
error: acpError.message,
|
|
490
|
+
stackTrace: acpError.stack
|
|
491
|
+
}
|
|
492
|
+
});
|
|
340
493
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
494
|
+
// Save error to database
|
|
495
|
+
const errorMsg = `ACP Error: ${acpError.message}`;
|
|
496
|
+
queries.createMessage(conversationId, 'assistant', errorMsg);
|
|
497
|
+
queries.updateSession(sessionId, { status: 'error', error: acpError.message, completed_at: Date.now() });
|
|
498
|
+
queries.createEvent('session.error', { error: acpError.message, stack: acpError.stack }, conversationId, sessionId);
|
|
499
|
+
broadcastSync({ type: 'session_updated', sessionId, status: 'error', error: acpError.message });
|
|
500
|
+
|
|
501
|
+
// Clean up ACP connection on error
|
|
502
|
+
acpPool.delete(actualAgentId);
|
|
503
|
+
throw acpError;
|
|
347
504
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
};
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
queries.updateSession(sessionId, { status: 'error', error: e.message, completed_at: Date.now() });
|
|
377
|
-
queries.createEvent('session.error', { error: e.message }, conversationId, sessionId);
|
|
378
|
-
broadcastSync({ type: 'session_updated', sessionId, status: 'error', error: e.message });
|
|
379
|
-
acpPool.delete(agentId || 'claude-code');
|
|
505
|
+
|
|
506
|
+
} catch (fatalError) {
|
|
507
|
+
console.error(`[processMessage] ❌ Fatal error: ${fatalError.message}`);
|
|
508
|
+
console.error(`[processMessage] Stack: ${fatalError.stack}`);
|
|
509
|
+
|
|
510
|
+
// Ensure state is in error
|
|
511
|
+
if (!stateManager.isTerminal()) {
|
|
512
|
+
stateManager.transition(stateManager.constructor.STATES.ERROR, {
|
|
513
|
+
reason: `Fatal error: ${fatalError.message}`,
|
|
514
|
+
data: {
|
|
515
|
+
error: fatalError.message,
|
|
516
|
+
stackTrace: fatalError.stack
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Log full state history for debugging
|
|
522
|
+
const summary = stateManager.getSummary();
|
|
523
|
+
console.error(`[processMessage] State history: ${JSON.stringify(summary, null, 2)}`);
|
|
524
|
+
|
|
525
|
+
} finally {
|
|
526
|
+
// Cleanup: remove from state store after completion
|
|
527
|
+
setTimeout(() => {
|
|
528
|
+
sessionStateStore.remove(sessionId);
|
|
529
|
+
}, 5000);
|
|
530
|
+
|
|
531
|
+
// Log final state
|
|
532
|
+
console.log(`[processMessage] Final state: ${stateManager.getState()}`);
|
|
380
533
|
}
|
|
381
534
|
}
|
|
382
535
|
|