agentgui 1.0.67 → 1.0.69

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.
Files changed (55) hide show
  1. package/.prd +92 -0
  2. package/.prd-browser +607 -0
  3. package/CLAUDE.md +1559 -125
  4. package/browser-test-harness.js +371 -0
  5. package/browser-test.js +409 -0
  6. package/execute-tests.js +164 -0
  7. package/lib/claude-runner.js +41 -12
  8. package/lib/database-service.ts +252 -0
  9. package/lib/sync-service.ts +275 -0
  10. package/lib/types.ts +168 -0
  11. package/package.json +1 -1
  12. package/readme.md +586 -0
  13. package/run-e2e-test.sh +88 -0
  14. package/server.js +274 -8
  15. package/static/index.html +487 -180
  16. package/static/js/client.js +558 -0
  17. package/static/js/event-filter.js +311 -0
  18. package/static/js/event-processor.js +454 -0
  19. package/static/js/streaming-renderer.js +813 -0
  20. package/static/js/syntax-highlighter.js +271 -0
  21. package/static/js/ui-components.js +433 -0
  22. package/static/js/websocket-manager.js +482 -0
  23. package/static/templates/INDEX.html +465 -0
  24. package/static/templates/README.md +190 -0
  25. package/static/templates/agent-capabilities.html +56 -0
  26. package/static/templates/agent-metadata-panel.html +44 -0
  27. package/static/templates/agent-status-badge.html +30 -0
  28. package/static/templates/code-annotation-panel.html +155 -0
  29. package/static/templates/code-suggestion-panel.html +184 -0
  30. package/static/templates/command-header.html +77 -0
  31. package/static/templates/command-output-scrollable.html +118 -0
  32. package/static/templates/elapsed-time.html +54 -0
  33. package/static/templates/error-alert.html +106 -0
  34. package/static/templates/error-history-timeline.html +160 -0
  35. package/static/templates/error-recovery-options.html +109 -0
  36. package/static/templates/error-stack-trace.html +95 -0
  37. package/static/templates/error-summary.html +80 -0
  38. package/static/templates/event-counter.html +48 -0
  39. package/static/templates/execution-actions.html +97 -0
  40. package/static/templates/execution-progress-bar.html +80 -0
  41. package/static/templates/execution-stepper.html +120 -0
  42. package/static/templates/file-breadcrumb.html +118 -0
  43. package/static/templates/file-diff-viewer.html +121 -0
  44. package/static/templates/file-metadata.html +133 -0
  45. package/static/templates/file-read-panel.html +66 -0
  46. package/static/templates/file-write-panel.html +120 -0
  47. package/static/templates/git-branch-remote.html +107 -0
  48. package/static/templates/git-diff-list.html +101 -0
  49. package/static/templates/git-log-visualization.html +153 -0
  50. package/static/templates/git-status-panel.html +115 -0
  51. package/static/templates/quality-metrics-display.html +170 -0
  52. package/static/templates/terminal-output-panel.html +87 -0
  53. package/static/templates/test-results-display.html +144 -0
  54. package/test-browser.js +457 -0
  55. package/test-runner.js +182 -0
@@ -15,6 +15,8 @@ import {
15
15
  ValidationResult,
16
16
  ValidationError,
17
17
  SyncError,
18
+ ExecutionMetadata,
19
+ StreamingEvent,
18
20
  } from './types';
19
21
  import {
20
22
  validateConversation,
@@ -369,6 +371,256 @@ export class DatabaseService {
369
371
  return { valid: errors.length === 0, errors };
370
372
  }
371
373
 
374
+ // =========================================================================
375
+ // STREAMING EXECUTION OPERATIONS
376
+ // =========================================================================
377
+
378
+ storeExecutionEvent(sessionId: string, event: StreamingEvent): void {
379
+ this.checkClosed();
380
+
381
+ try {
382
+ const stmt = this.db.prepare(
383
+ 'INSERT INTO execution_events (sessionId, eventType, eventData, timestamp) VALUES (?, ?, ?, ?)'
384
+ );
385
+ stmt.run(sessionId, event.type, JSON.stringify(event), event.timestamp || Date.now());
386
+ } catch (err) {
387
+ throw new SyncError(
388
+ 'DATABASE_ERROR',
389
+ `Failed to store execution event: ${(err as Error).message}`,
390
+ true,
391
+ { sessionId, eventType: event.type }
392
+ );
393
+ }
394
+ }
395
+
396
+ storeExecutionMetadata(sessionId: string, metadata: ExecutionMetadata): void {
397
+ this.checkClosed();
398
+
399
+ try {
400
+ const stmt = this.db.prepare(
401
+ 'INSERT OR REPLACE INTO execution_metadata (sessionId, metadata, updated_at) VALUES (?, ?, ?)'
402
+ );
403
+ stmt.run(sessionId, JSON.stringify(metadata), Date.now());
404
+ } catch (err) {
405
+ throw new SyncError(
406
+ 'DATABASE_ERROR',
407
+ `Failed to store execution metadata: ${(err as Error).message}`,
408
+ true,
409
+ { sessionId }
410
+ );
411
+ }
412
+ }
413
+
414
+ getSessionExecutionHistory(sessionId: string, limit = 1000, offset = 0): StreamingEvent[] {
415
+ this.checkClosed();
416
+
417
+ try {
418
+ const stmt = this.db.prepare(
419
+ 'SELECT eventData FROM execution_events WHERE sessionId = ? ORDER BY timestamp ASC LIMIT ? OFFSET ?'
420
+ );
421
+ const rows = stmt.all(sessionId, limit, offset);
422
+
423
+ return rows.map((row) => {
424
+ try {
425
+ return JSON.parse(row.eventData);
426
+ } catch (e) {
427
+ console.warn('[DatabaseService] Invalid execution event JSON:', row.eventData);
428
+ return null;
429
+ }
430
+ }).filter((e): e is StreamingEvent => e !== null);
431
+ } catch (err) {
432
+ throw new SyncError(
433
+ 'DATABASE_ERROR',
434
+ `Failed to get execution history: ${(err as Error).message}`,
435
+ true,
436
+ { sessionId, limit, offset }
437
+ );
438
+ }
439
+ }
440
+
441
+ getExecutionMetadata(sessionId: string): ExecutionMetadata | null {
442
+ this.checkClosed();
443
+
444
+ try {
445
+ const stmt = this.db.prepare('SELECT metadata FROM execution_metadata WHERE sessionId = ?');
446
+ const row = stmt.get(sessionId);
447
+
448
+ if (!row) return null;
449
+
450
+ try {
451
+ return JSON.parse(row.metadata);
452
+ } catch (e) {
453
+ throw new SyncError('VALIDATION_ERROR', `Invalid metadata JSON for session ${sessionId}`, false);
454
+ }
455
+ } catch (err) {
456
+ if (err instanceof SyncError) throw err;
457
+ throw new SyncError(
458
+ 'DATABASE_ERROR',
459
+ `Failed to get execution metadata: ${(err as Error).message}`,
460
+ true,
461
+ { sessionId }
462
+ );
463
+ }
464
+ }
465
+
466
+ batchStoreExecutionEvents(sessionId: string, events: StreamingEvent[]): { count: number; latencyMs: number } {
467
+ this.checkClosed();
468
+
469
+ const startTime = Date.now();
470
+ try {
471
+ const transaction = this.db.transaction(() => {
472
+ const stmt = this.db.prepare(
473
+ 'INSERT INTO execution_events (sessionId, eventType, eventData, timestamp) VALUES (?, ?, ?, ?)'
474
+ );
475
+
476
+ for (const event of events) {
477
+ stmt.run(sessionId, event.type, JSON.stringify(event), event.timestamp || Date.now());
478
+ }
479
+ });
480
+
481
+ transaction();
482
+ const latencyMs = Date.now() - startTime;
483
+
484
+ if (latencyMs > 100) {
485
+ console.warn(`[DatabaseService] Batch write latency ${latencyMs}ms for ${events.length} events`);
486
+ }
487
+
488
+ return { count: events.length, latencyMs };
489
+ } catch (err) {
490
+ if (err instanceof SyncError) throw err;
491
+ throw new SyncError(
492
+ 'DATABASE_ERROR',
493
+ `Failed to batch store execution events: ${(err as Error).message}`,
494
+ true,
495
+ { sessionId, count: events.length }
496
+ );
497
+ }
498
+ }
499
+
500
+ batchStoreExecutionEventsOptimized(
501
+ sessionId: string,
502
+ events: StreamingEvent[],
503
+ commitInterval = 100
504
+ ): { count: number; batches: number; totalLatencyMs: number } {
505
+ this.checkClosed();
506
+
507
+ const startTime = Date.now();
508
+ let processedCount = 0;
509
+ let batchCount = 0;
510
+
511
+ try {
512
+ const stmt = this.db.prepare(
513
+ 'INSERT INTO execution_events (sessionId, eventType, eventData, timestamp) VALUES (?, ?, ?, ?)'
514
+ );
515
+
516
+ // Process in batches with commit points
517
+ for (let i = 0; i < events.length; i += commitInterval) {
518
+ const batch = events.slice(i, i + commitInterval);
519
+ const transaction = this.db.transaction(() => {
520
+ for (const event of batch) {
521
+ stmt.run(sessionId, event.type, JSON.stringify(event), event.timestamp || Date.now());
522
+ processedCount++;
523
+ }
524
+ });
525
+
526
+ transaction();
527
+ batchCount++;
528
+
529
+ const elapsed = Date.now() - startTime;
530
+ if (elapsed > 5000) {
531
+ console.warn(`[DatabaseService] Long batch write: ${elapsed}ms for ${processedCount}/${events.length} events`);
532
+ }
533
+ }
534
+
535
+ const totalLatencyMs = Date.now() - startTime;
536
+ return { count: events.length, batches: batchCount, totalLatencyMs };
537
+ } catch (err) {
538
+ throw new SyncError(
539
+ 'DATABASE_ERROR',
540
+ `Failed to batch store execution events (processed ${processedCount}/${events.length}): ${(err as Error).message}`,
541
+ true,
542
+ { sessionId, count: events.length, processed: processedCount }
543
+ );
544
+ }
545
+ }
546
+
547
+ markSessionIncomplete(sessionId: string, reason: string): void {
548
+ this.checkClosed();
549
+
550
+ try {
551
+ const stmt = this.db.prepare(
552
+ 'UPDATE sessions SET status = ?, error = ?, completed_at = ? WHERE id = ?'
553
+ );
554
+ stmt.run('incomplete', reason, Date.now(), sessionId);
555
+ } catch (err) {
556
+ throw new SyncError(
557
+ 'DATABASE_ERROR',
558
+ `Failed to mark session incomplete: ${(err as Error).message}`,
559
+ true,
560
+ { sessionId, reason }
561
+ );
562
+ }
563
+ }
564
+
565
+ getIncompleteSessionsOlderThan(ageMinutes = 30): Array<{ id: string; started_at: number }> {
566
+ this.checkClosed();
567
+
568
+ try {
569
+ const cutoffTime = Date.now() - (ageMinutes * 60 * 1000);
570
+ const stmt = this.db.prepare(
571
+ 'SELECT id, started_at FROM sessions WHERE status = ? AND started_at < ?'
572
+ );
573
+ return stmt.all('incomplete', cutoffTime);
574
+ } catch (err) {
575
+ throw new SyncError(
576
+ 'DATABASE_ERROR',
577
+ `Failed to get incomplete sessions: ${(err as Error).message}`,
578
+ true,
579
+ { ageMinutes }
580
+ );
581
+ }
582
+ }
583
+
584
+ markSessionComplete(sessionId: string, status: 'completed' | 'error' | 'timeout'): void {
585
+ this.checkClosed();
586
+
587
+ try {
588
+ const stmt = this.db.prepare(
589
+ 'UPDATE sessions SET status = ?, completed_at = ? WHERE id = ?'
590
+ );
591
+ stmt.run(status, Date.now(), sessionId);
592
+ } catch (err) {
593
+ throw new SyncError(
594
+ 'DATABASE_ERROR',
595
+ `Failed to mark session complete: ${(err as Error).message}`,
596
+ true,
597
+ { sessionId, status }
598
+ );
599
+ }
600
+ }
601
+
602
+ cleanupOrphanedSessions(maxAgeDays = 7): number {
603
+ this.checkClosed();
604
+
605
+ try {
606
+ const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
607
+ const cutoffTime = Date.now() - maxAgeMs;
608
+
609
+ const stmt = this.db.prepare(
610
+ 'DELETE FROM sessions WHERE status NOT IN (?, ?, ?) AND started_at < ?'
611
+ );
612
+ const result = stmt.run('processing', 'pending', 'incomplete', cutoffTime);
613
+ return (result.changes || 0);
614
+ } catch (err) {
615
+ throw new SyncError(
616
+ 'DATABASE_ERROR',
617
+ `Failed to cleanup orphaned sessions: ${(err as Error).message}`,
618
+ true,
619
+ { maxAgeDays }
620
+ );
621
+ }
622
+ }
623
+
372
624
  // =========================================================================
373
625
  // LIFECYCLE
374
626
  // =========================================================================
@@ -13,6 +13,8 @@ import {
13
13
  SyncStatus,
14
14
  SyncError,
15
15
  ConflictResolutionStrategy,
16
+ StreamingEvent,
17
+ ExecutionMetadata,
16
18
  } from './types';
17
19
  import DatabaseService from './database-service';
18
20
 
@@ -313,6 +315,152 @@ export class SyncService extends EventEmitter {
313
315
  this.emit('operation:processed', op);
314
316
  }
315
317
 
318
+ // =========================================================================
319
+ // STREAMING EXECUTION SYNC
320
+ // =========================================================================
321
+
322
+ async syncSessionExecution(sessionId: string, events: StreamingEvent[]): Promise<SyncStatus> {
323
+ try {
324
+ this.emit('sync:start', { type: 'streaming', sessionId });
325
+
326
+ // Deduplicate by eventId (if present)
327
+ const dedupMap = new Map<string, StreamingEvent>();
328
+ for (const event of events) {
329
+ const key = event.eventId || `${event.type}:${event.timestamp}`;
330
+ dedupMap.set(key, event);
331
+ }
332
+
333
+ // Store all execution events in batch
334
+ const uniqueEvents = Array.from(dedupMap.values());
335
+ this.db.batchStoreExecutionEvents(sessionId, uniqueEvents);
336
+
337
+ this.emit('sync:complete', {
338
+ type: 'streaming',
339
+ sessionId,
340
+ eventCount: uniqueEvents.length,
341
+ });
342
+
343
+ return {
344
+ state: 'synced',
345
+ lastSyncTime: Date.now(),
346
+ retryCount: 0,
347
+ maxRetries: this.options.retryAttempts,
348
+ };
349
+ } catch (err) {
350
+ return this.handleSyncError(err as Error);
351
+ }
352
+ }
353
+
354
+ async syncExecutionMetadata(sessionId: string, metadata: ExecutionMetadata): Promise<SyncStatus> {
355
+ try {
356
+ this.emit('sync:start', { type: 'metadata', sessionId });
357
+
358
+ this.db.storeExecutionMetadata(sessionId, metadata);
359
+
360
+ this.emit('sync:complete', {
361
+ type: 'metadata',
362
+ sessionId,
363
+ metadata,
364
+ });
365
+
366
+ return {
367
+ state: 'synced',
368
+ lastSyncTime: Date.now(),
369
+ retryCount: 0,
370
+ maxRetries: this.options.retryAttempts,
371
+ };
372
+ } catch (err) {
373
+ return this.handleSyncError(err as Error);
374
+ }
375
+ }
376
+
377
+ async flushStreamingQueue(): Promise<{ flushed: number; failed: number; deduplicated: number }> {
378
+ if (this.pendingOperations.size === 0) {
379
+ return { flushed: 0, failed: 0, deduplicated: 0 };
380
+ }
381
+
382
+ const ops = Array.from(this.pendingOperations.values());
383
+ this.pendingOperations.clear();
384
+
385
+ // Dedup by operation key
386
+ const dedupMap = new Map<string, SyncEvent>();
387
+ let dedupCount = 0;
388
+ for (const op of ops) {
389
+ const key = `${op.type}:${op.data.id || op.data.sessionId || 'global'}`;
390
+ if (dedupMap.has(key)) {
391
+ dedupCount++;
392
+ } else {
393
+ dedupMap.set(key, op);
394
+ }
395
+ }
396
+
397
+ // Group by type for ordering preservation
398
+ const byType = new Map<string, SyncEvent[]>();
399
+ for (const op of dedupMap.values()) {
400
+ if (!byType.has(op.type)) byType.set(op.type, []);
401
+ byType.get(op.type)!.push(op);
402
+ }
403
+
404
+ // Sort by timestamp within each type
405
+ for (const events of byType.values()) {
406
+ events.sort((a, b) => a.timestamp - b.timestamp);
407
+ }
408
+
409
+ let flushed = 0;
410
+ let failed = 0;
411
+
412
+ for (const [type, events] of byType) {
413
+ for (const op of events) {
414
+ try {
415
+ await this.processOperation(op);
416
+ this.emit('queue:item_processed', op);
417
+ flushed++;
418
+ } catch (err) {
419
+ this.emit('queue:error', {
420
+ operation: op,
421
+ error: (err as Error).message,
422
+ });
423
+ this.queueOperation(op);
424
+ failed++;
425
+ }
426
+ }
427
+ }
428
+
429
+ this.emit('queue:flushed', { flushed, failed, deduplicated: dedupCount });
430
+ return { flushed, failed, deduplicated: dedupCount };
431
+ }
432
+
433
+ async flushStreamingQueueWithTimeout(timeoutMs = 30000): Promise<{ flushed: number; failed: number; timedOut: boolean }> {
434
+ const startTime = Date.now();
435
+ let flushed = 0;
436
+ let failed = 0;
437
+ let timedOut = false;
438
+
439
+ try {
440
+ while (this.pendingOperations.size > 0) {
441
+ const elapsed = Date.now() - startTime;
442
+ if (elapsed > timeoutMs) {
443
+ timedOut = true;
444
+ break;
445
+ }
446
+
447
+ const result = await this.flushStreamingQueue();
448
+ flushed += result.flushed;
449
+ failed += result.failed;
450
+
451
+ if (result.flushed === 0) break;
452
+ }
453
+ } catch (err) {
454
+ this.emit('queue:timeout', {
455
+ error: (err as Error).message,
456
+ flushed,
457
+ failed,
458
+ });
459
+ }
460
+
461
+ return { flushed, failed, timedOut };
462
+ }
463
+
316
464
  // =========================================================================
317
465
  // STATUS & INFO
318
466
  // =========================================================================
@@ -335,6 +483,133 @@ export class SyncService extends EventEmitter {
335
483
  this.retryAttempts = 0;
336
484
  this.lastSyncTime = 0;
337
485
  }
486
+
487
+ // =========================================================================
488
+ // RECOVERY & RESILIENCE
489
+ // =========================================================================
490
+
491
+ async recoverIncompleteStreams(): Promise<{ recovered: number; failed: number; timedOut: number }> {
492
+ try {
493
+ this.emit('recovery:start', { type: 'incomplete_streams' });
494
+
495
+ const incompleteSessionsOlderThan30Min = this.db.getIncompleteSessionsOlderThan(30);
496
+ let recovered = 0;
497
+ let failed = 0;
498
+ let timedOut = 0;
499
+
500
+ for (const session of incompleteSessionsOlderThan30Min) {
501
+ try {
502
+ const ageMinutes = (Date.now() - session.started_at) / (60 * 1000);
503
+
504
+ if (ageMinutes > 120) {
505
+ // 2 hour timeout
506
+ this.db.markSessionComplete(session.id, 'timeout');
507
+ timedOut++;
508
+ this.emit('recovery:timeout', { sessionId: session.id, ageMinutes });
509
+ } else {
510
+ // Mark for retry
511
+ this.queueOperation({
512
+ type: 'retry_incomplete_session',
513
+ timestamp: Date.now(),
514
+ data: { sessionId: session.id }
515
+ });
516
+ recovered++;
517
+ this.emit('recovery:queued', { sessionId: session.id });
518
+ }
519
+ } catch (err) {
520
+ failed++;
521
+ this.emit('recovery:error', {
522
+ sessionId: session.id,
523
+ error: (err as Error).message,
524
+ });
525
+ }
526
+ }
527
+
528
+ this.emit('recovery:complete', { recovered, failed, timedOut });
529
+ return { recovered, failed, timedOut };
530
+ } catch (err) {
531
+ this.emit('recovery:failed', { error: (err as Error).message });
532
+ return { recovered: 0, failed: 0, timedOut: 0 };
533
+ }
534
+ }
535
+
536
+ async resolveExecutionConflicts(
537
+ sessionId: string,
538
+ localMetadata: ExecutionMetadata,
539
+ remoteMetadata: ExecutionMetadata
540
+ ): Promise<ExecutionMetadata> {
541
+ try {
542
+ // Last-write-wins strategy: use the one with latest completion time
543
+ const localTime = localMetadata.endTime || 0;
544
+ const remoteTime = remoteMetadata.endTime || 0;
545
+
546
+ const winner = remoteTime >= localTime ? remoteMetadata : localMetadata;
547
+
548
+ // Merge metadata: take token counts from both, use max
549
+ const merged: ExecutionMetadata = {
550
+ ...winner,
551
+ inputTokens: Math.max(localMetadata.inputTokens || 0, remoteMetadata.inputTokens || 0),
552
+ outputTokens: Math.max(localMetadata.outputTokens || 0, remoteMetadata.outputTokens || 0),
553
+ totalTokens: Math.max(localMetadata.totalTokens || 0, remoteMetadata.totalTokens || 0),
554
+ toolCalls: Math.max(localMetadata.toolCalls, remoteMetadata.toolCalls),
555
+ toolResults: Math.max(localMetadata.toolResults, remoteMetadata.toolResults),
556
+ errorCount: Math.max(localMetadata.errorCount, remoteMetadata.errorCount),
557
+ };
558
+
559
+ this.db.storeExecutionMetadata(sessionId, merged);
560
+
561
+ this.emit('conflict:resolved', {
562
+ sessionId,
563
+ strategy: 'last-write-wins',
564
+ winner: remoteTime >= localTime ? 'remote' : 'local',
565
+ merged,
566
+ });
567
+
568
+ return merged;
569
+ } catch (err) {
570
+ this.emit('conflict:error', {
571
+ sessionId,
572
+ error: (err as Error).message,
573
+ });
574
+ throw err;
575
+ }
576
+ }
577
+
578
+ async detectAndResolveConflicts(): Promise<{ resolved: number; failed: number }> {
579
+ try {
580
+ this.emit('conflict:detection_start');
581
+
582
+ // This would scan for duplicate sessions with same conversation+timestamp
583
+ // For now, return placeholder
584
+ let resolved = 0;
585
+ let failed = 0;
586
+
587
+ this.emit('conflict:detection_complete', { resolved, failed });
588
+ return { resolved, failed };
589
+ } catch (err) {
590
+ this.emit('conflict:detection_failed', { error: (err as Error).message });
591
+ return { resolved: 0, failed: 0 };
592
+ }
593
+ }
594
+
595
+ async flushOfflineQueue(): Promise<{ flushed: number; failed: number; retried: number }> {
596
+ try {
597
+ this.emit('offline_queue:flush_start');
598
+
599
+ const result = await this.flushStreamingQueueWithTimeout(60000);
600
+
601
+ this.emit('offline_queue:flush_complete', {
602
+ flushed: result.flushed,
603
+ failed: result.failed,
604
+ timedOut: result.timedOut,
605
+ });
606
+
607
+ return { flushed: result.flushed, failed: result.failed, retried: 0 };
608
+ } catch (err) {
609
+ this.emit('offline_queue:flush_error', { error: (err as Error).message });
610
+ return { flushed: 0, failed: 0, retried: 0 };
611
+ }
612
+ }
338
613
  }
339
614
 
340
615
  export default SyncService;