@myrialabs/clopen 0.1.9 → 0.2.0

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 (94) hide show
  1. package/README.md +23 -1
  2. package/backend/index.ts +25 -1
  3. package/backend/lib/auth/auth-service.ts +484 -0
  4. package/backend/lib/auth/index.ts +4 -0
  5. package/backend/lib/auth/permissions.ts +63 -0
  6. package/backend/lib/auth/rate-limiter.ts +145 -0
  7. package/backend/lib/auth/tokens.ts +53 -0
  8. package/backend/lib/chat/stream-manager.ts +4 -1
  9. package/backend/lib/database/migrations/024_create_users_table.ts +29 -0
  10. package/backend/lib/database/migrations/025_create_auth_sessions_table.ts +38 -0
  11. package/backend/lib/database/migrations/026_create_invite_tokens_table.ts +31 -0
  12. package/backend/lib/database/migrations/index.ts +21 -0
  13. package/backend/lib/database/queries/auth-queries.ts +201 -0
  14. package/backend/lib/database/queries/index.ts +2 -1
  15. package/backend/lib/database/queries/session-queries.ts +13 -0
  16. package/backend/lib/database/queries/snapshot-queries.ts +1 -1
  17. package/backend/lib/engine/adapters/opencode/server.ts +9 -1
  18. package/backend/lib/engine/adapters/opencode/stream.ts +175 -1
  19. package/backend/lib/mcp/config.ts +13 -18
  20. package/backend/lib/mcp/index.ts +9 -0
  21. package/backend/lib/mcp/remote-server.ts +132 -0
  22. package/backend/lib/mcp/servers/helper.ts +49 -3
  23. package/backend/lib/mcp/servers/index.ts +3 -2
  24. package/backend/lib/preview/browser/browser-audio-capture.ts +20 -3
  25. package/backend/lib/preview/browser/browser-navigation-tracker.ts +3 -0
  26. package/backend/lib/preview/browser/browser-pool.ts +73 -176
  27. package/backend/lib/preview/browser/browser-preview-service.ts +3 -2
  28. package/backend/lib/preview/browser/browser-tab-manager.ts +261 -23
  29. package/backend/lib/preview/browser/browser-video-capture.ts +36 -1
  30. package/backend/lib/snapshot/helpers.ts +22 -49
  31. package/backend/lib/snapshot/snapshot-service.ts +148 -83
  32. package/backend/lib/utils/ws.ts +65 -1
  33. package/backend/ws/auth/index.ts +17 -0
  34. package/backend/ws/auth/invites.ts +84 -0
  35. package/backend/ws/auth/login.ts +269 -0
  36. package/backend/ws/auth/status.ts +41 -0
  37. package/backend/ws/auth/users.ts +32 -0
  38. package/backend/ws/chat/stream.ts +13 -0
  39. package/backend/ws/engine/claude/accounts.ts +3 -1
  40. package/backend/ws/engine/utils.ts +38 -6
  41. package/backend/ws/index.ts +4 -4
  42. package/backend/ws/preview/browser/interact.ts +27 -5
  43. package/backend/ws/snapshot/restore.ts +111 -12
  44. package/backend/ws/snapshot/timeline.ts +56 -29
  45. package/bin/clopen.ts +56 -1
  46. package/bun.lock +113 -51
  47. package/frontend/App.svelte +47 -29
  48. package/frontend/lib/components/auth/InvitePage.svelte +215 -0
  49. package/frontend/lib/components/auth/LoginPage.svelte +129 -0
  50. package/frontend/lib/components/auth/SetupPage.svelte +1022 -0
  51. package/frontend/lib/components/chat/input/ChatInput.svelte +1 -2
  52. package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +2 -2
  53. package/frontend/lib/components/chat/input/composables/use-chat-actions.svelte.ts +4 -4
  54. package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +11 -19
  55. package/frontend/lib/components/checkpoint/TimelineModal.svelte +15 -3
  56. package/frontend/lib/components/checkpoint/timeline/TimelineNode.svelte +30 -19
  57. package/frontend/lib/components/checkpoint/timeline/types.ts +4 -0
  58. package/frontend/lib/components/common/FolderBrowser.svelte +9 -9
  59. package/frontend/lib/components/common/UpdateBanner.svelte +2 -2
  60. package/frontend/lib/components/git/CommitForm.svelte +6 -4
  61. package/frontend/lib/components/history/HistoryModal.svelte +1 -1
  62. package/frontend/lib/components/history/HistoryView.svelte +1 -1
  63. package/frontend/lib/components/preview/browser/BrowserPreview.svelte +1 -1
  64. package/frontend/lib/components/preview/browser/core/mcp-handlers.svelte.ts +12 -4
  65. package/frontend/lib/components/settings/SettingsModal.svelte +50 -15
  66. package/frontend/lib/components/settings/SettingsView.svelte +21 -7
  67. package/frontend/lib/components/settings/account/AccountSettings.svelte +5 -0
  68. package/frontend/lib/components/settings/admin/InviteManagement.svelte +239 -0
  69. package/frontend/lib/components/settings/admin/UserManagement.svelte +127 -0
  70. package/frontend/lib/components/settings/general/AdvancedSettings.svelte +10 -4
  71. package/frontend/lib/components/settings/general/AuthModeSettings.svelte +229 -0
  72. package/frontend/lib/components/settings/general/GeneralSettings.svelte +6 -1
  73. package/frontend/lib/components/settings/general/UpdateSettings.svelte +5 -5
  74. package/frontend/lib/components/settings/security/SecuritySettings.svelte +10 -0
  75. package/frontend/lib/components/settings/system/SystemSettings.svelte +10 -0
  76. package/frontend/lib/components/settings/user/UserSettings.svelte +147 -74
  77. package/frontend/lib/components/workspace/PanelHeader.svelte +1 -1
  78. package/frontend/lib/components/workspace/WorkspaceLayout.svelte +5 -10
  79. package/frontend/lib/components/workspace/panels/ChatPanel.svelte +3 -2
  80. package/frontend/lib/services/preview/browser/browser-webcodecs.service.ts +31 -8
  81. package/frontend/lib/stores/core/sessions.svelte.ts +15 -1
  82. package/frontend/lib/stores/features/auth.svelte.ts +296 -0
  83. package/frontend/lib/stores/features/settings.svelte.ts +53 -9
  84. package/frontend/lib/stores/features/user.svelte.ts +26 -68
  85. package/frontend/lib/stores/ui/settings-modal.svelte.ts +42 -21
  86. package/frontend/lib/stores/ui/update.svelte.ts +2 -14
  87. package/frontend/lib/stores/ui/workspace.svelte.ts +4 -4
  88. package/package.json +8 -6
  89. package/shared/types/stores/settings.ts +16 -2
  90. package/shared/utils/logger.ts +1 -0
  91. package/shared/utils/ws-client.ts +30 -13
  92. package/shared/utils/ws-server.ts +42 -4
  93. package/backend/lib/mcp/stdio-server.ts +0 -103
  94. package/backend/ws/mcp/index.ts +0 -61
@@ -623,41 +623,115 @@ export class BrowserTabManager extends EventEmitter {
623
623
  page.setDefaultNavigationTimeout(30000);
624
624
 
625
625
  // Configure page for stability
626
- await page.setExtraHTTPHeaders({
627
- 'Accept-Language': 'en-US,en;q=0.9',
628
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
629
- });
630
-
631
- // Optimize font loading to prevent screenshot timeouts
632
- await page.evaluateOnNewDocument(() => {
633
- // Disable font loading wait
634
- Object.defineProperty(document, 'fonts', {
635
- value: {
636
- ready: Promise.resolve(),
637
- load: () => Promise.resolve([]),
638
- check: () => true,
639
- addEventListener: () => {},
640
- removeEventListener: () => {}
641
- }
642
- });
643
- });
626
+ // Note: Do NOT set HTTP headers manually here (like Accept-Language).
627
+ // Setting extra headers alters the HTTP/2 pseudo-header order and capitalization
628
+ // which immediately gets flagged by Cloudflare's TLS/Fingerprint matching algorithms.
644
629
 
645
- // Inject audio capture script BEFORE page loads to intercept AudioContext
646
- await this.audioCapture.setupAudioCapture(page, DEFAULT_STREAMING_CONFIG.audio);
630
+ // Audio capture is injected post-navigation in BrowserVideoCapture.startStreaming()
631
+ // to avoid Cloudflare fingerprint detection of AudioContext constructor patching.
647
632
 
648
633
  // Simplified cursor tracking for visual feedback only
649
634
  await this.injectCursorTracking(page);
635
+
636
+ // Suppress Cloudflare Turnstile error callbacks to prevent sites from showing
637
+ // "CAPTCHA verification failed" popups in headless Chrome (error 600010).
638
+ //
639
+ // Strategy: Let the real Turnstile script load and define window.turnstile normally
640
+ // (needed for CF Managed Challenge auto-pass via StealthPlugin fingerprinting).
641
+ // Intercept window.turnstile assignment via getter/setter and patch render()/execute()
642
+ // to replace error-callback/expired-callback with no-ops before they are registered.
643
+ // Also strip data-error-callback attributes from DOM elements via MutationObserver
644
+ // to cover implicit render (data-sitekey) usage.
645
+ //
646
+ // This does NOT block challenges.cloudflare.com — CF Managed Challenge needs that
647
+ // URL to run its JS verification. Only the error reporting path is suppressed.
648
+ await page.evaluateOnNewDocument(function () {
649
+ (function () {
650
+
651
+ let _turnstile: any;
652
+
653
+ function patchOptions(options: Record<string, unknown>) {
654
+ return Object.assign({}, options, {
655
+ 'error-callback': function () {},
656
+ 'expired-callback': function () {}
657
+ });
658
+ }
659
+
660
+
661
+ function patchApi(api: any) {
662
+ if (!api || typeof api !== 'object') return api;
663
+ ['render', 'execute'].forEach(function (method: string) {
664
+ if (typeof api[method] === 'function') {
665
+ const orig = api[method].bind(api);
666
+ api[method] = function (container: unknown, opts: Record<string, unknown>) {
667
+ return orig(container, patchOptions(opts || {}));
668
+ };
669
+ }
670
+ });
671
+ return api;
672
+ }
673
+
674
+ try {
675
+ Object.defineProperty(window, 'turnstile', {
676
+ configurable: true,
677
+ enumerable: true,
678
+
679
+ get() { return _turnstile; },
680
+
681
+ set(val: any) { _turnstile = patchApi(val); }
682
+ });
683
+ } catch {
684
+ // Property already defined or can't be intercepted
685
+ }
686
+
687
+ // Strip data-error-callback / data-expired-callback from Turnstile elements
688
+ // before implicit render reads them, so no error handler is registered.
689
+ function stripErrorAttrs(el: Element) {
690
+ el.removeAttribute('data-error-callback');
691
+ el.removeAttribute('data-expired-callback');
692
+ }
693
+
694
+ const mo = new MutationObserver(function (mutations) {
695
+ mutations.forEach(function (m) {
696
+ m.addedNodes.forEach(function (node) {
697
+ if (!(node instanceof Element)) return;
698
+ if (node.hasAttribute('data-error-callback') || node.hasAttribute('data-expired-callback')) {
699
+ stripErrorAttrs(node);
700
+ }
701
+ node.querySelectorAll('[data-error-callback],[data-expired-callback]').forEach(stripErrorAttrs);
702
+ });
703
+ });
704
+ });
705
+ if (document.documentElement) {
706
+ mo.observe(document.documentElement, { childList: true, subtree: true });
707
+ }
708
+ })();
709
+ });
710
+
711
+ // Auto-dismiss native browser dialogs to prevent page hang in headless mode.
712
+ // alert() → accept (one-way informational, safe to dismiss)
713
+ // confirm()/prompt() → dismiss/cancel (avoid unintended side effects)
714
+ // CAPTCHA-related alerts are silently dismissed.
715
+ page.on('dialog', async (dialog) => {
716
+ const type = dialog.type();
717
+ if (type === 'alert') {
718
+ await dialog.accept().catch(() => {});
719
+ } else {
720
+ await dialog.dismiss().catch(() => {});
721
+ }
722
+ });
650
723
  }
651
724
 
652
725
  /**
653
726
  * Inject cursor tracking script
654
727
  */
655
728
  private async injectCursorTracking(page: Page) {
656
- await page.evaluateOnNewDocument(cursorTrackingScript);
729
+ // Temporarily disabled mapping logic as CloudFlare frequently flags evaluateOnNewDocument injected tracking events
730
+ // await page.evaluateOnNewDocument(cursorTrackingScript);
657
731
  }
658
732
 
659
733
  /**
660
- * Navigate with retry
734
+ * Navigate with retry, including Cloudflare auto-pass detection and CAPTCHA popup dismissal.
661
735
  */
662
736
  private async navigateWithRetry(page: Page, url: string): Promise<string> {
663
737
  let retries = 3;
@@ -669,7 +743,9 @@ export class BrowserTabManager extends EventEmitter {
669
743
  waitUntil: 'domcontentloaded',
670
744
  timeout: 30000
671
745
  });
672
- actualUrl = page.url();
746
+ actualUrl = await this.waitForCloudflareIfPresent(page);
747
+ // Dismiss any CAPTCHA failure popups from embedded Turnstile widgets
748
+ await this.dismissCaptchaPopupsIfPresent(page);
673
749
  break;
674
750
  } catch (error) {
675
751
  retries--;
@@ -684,6 +760,168 @@ export class BrowserTabManager extends EventEmitter {
684
760
  return actualUrl;
685
761
  }
686
762
 
763
+ /**
764
+ * Detect Cloudflare challenge page and wait for auto-pass redirect.
765
+ * Loops up to MAX_CF_RETRIES times to handle infinite verify loops where
766
+ * Cloudflare keeps redirecting back to a new challenge after each pass.
767
+ */
768
+ private async waitForCloudflareIfPresent(page: Page): Promise<string> {
769
+ const MAX_CF_RETRIES = 5;
770
+
771
+ for (let attempt = 0; attempt < MAX_CF_RETRIES; attempt++) {
772
+ let isChallenge = false;
773
+
774
+ try {
775
+ isChallenge = await page.evaluate(() => {
776
+ const title = document.title;
777
+ const bodyText = (document.body?.innerText || '').slice(0, 500).toLowerCase();
778
+ return (
779
+ // Old automated CF challenge
780
+ title === 'Just a moment...' ||
781
+ // Newer interactive CF challenge ("Performing security verification")
782
+ title.toLowerCase().includes('security verification') ||
783
+ bodyText.includes('verify you are human') ||
784
+ bodyText.includes('performing security verification') ||
785
+ // CF challenge DOM elements (reliable, present on challenge pages only)
786
+ document.getElementById('challenge-running') !== null ||
787
+ document.getElementById('cf-challenge-running') !== null ||
788
+ document.getElementById('challenge-form') !== null ||
789
+ document.querySelector('#challenge-stage') !== null
790
+ );
791
+ });
792
+ } catch {
793
+ // Page not evaluable (navigating, closed) — not a CF challenge
794
+ break;
795
+ }
796
+
797
+ if (!isChallenge) {
798
+ break;
799
+ }
800
+
801
+ debug.log('preview', `🛡️ Cloudflare challenge detected (attempt ${attempt + 1}/${MAX_CF_RETRIES}), waiting for auto-pass...`);
802
+
803
+ try {
804
+ await page.waitForNavigation({
805
+ waitUntil: 'domcontentloaded',
806
+ timeout: 20000
807
+ });
808
+ debug.log('preview', `✅ Cloudflare navigation → ${page.url()}`);
809
+ } catch {
810
+ debug.warn('preview', `⚠️ Cloudflare auto-pass timed out on attempt ${attempt + 1}, proceeding`);
811
+ break;
812
+ }
813
+ }
814
+
815
+ return page.url();
816
+ }
817
+
818
+ /**
819
+ * Inject a persistent non-blocking watcher into the page that auto-dismisses
820
+ * CAPTCHA failure popups whenever they appear (Cloudflare Turnstile error 600010,
821
+ * reCAPTCHA failures, etc.).
822
+ *
823
+ * Uses MutationObserver + setInterval so it catches popups regardless of when they
824
+ * appear after page load. Returns immediately — the watcher runs inside the page.
825
+ */
826
+ private async dismissCaptchaPopupsIfPresent(page: Page): Promise<void> {
827
+ try {
828
+ await page.evaluate(() => {
829
+ const CAPTCHA_WORDS = ['captcha', 'turnstile', 'human verification', 'robot', 'bot detected'];
830
+ const FAIL_WORDS = ['failed', 'error', 'invalid', 'verification failed', 'try again', 'unable to verify'];
831
+ const DISMISS_LABELS = ['ok', 'close', 'dismiss', 'cancel', 'retry', 'try again', 'continue', 'got it'];
832
+
833
+ const isCaptchaText = (text: string): boolean => {
834
+ const t = text.toLowerCase();
835
+ return (
836
+ CAPTCHA_WORDS.some(w => t.includes(w)) &&
837
+ FAIL_WORDS.some(w => t.includes(w))
838
+ );
839
+ };
840
+
841
+ const tryDismiss = (): boolean => {
842
+ // Strategy 1: click a dismiss button whose ancestor contains CAPTCHA failure text
843
+ const buttons = Array.from(document.querySelectorAll<HTMLElement>(
844
+ 'button, input[type="button"], input[type="submit"], a[role="button"]'
845
+ ));
846
+ for (const btn of buttons) {
847
+ const label = (
848
+ btn instanceof HTMLInputElement ? btn.value : btn.innerText || btn.textContent || ''
849
+ ).trim().toLowerCase();
850
+ if (!DISMISS_LABELS.includes(label)) continue;
851
+
852
+ let el: Element | null = btn.parentElement;
853
+ while (el && el !== document.body) {
854
+ if (isCaptchaText((el as HTMLElement).innerText || '')) {
855
+ (btn as HTMLElement).click();
856
+ return true;
857
+ }
858
+ el = el.parentElement;
859
+ }
860
+ }
861
+
862
+ // Strategy 2: hide any visible modal/overlay containing CAPTCHA failure text
863
+ const overlaySelectors = [
864
+ '[class*="modal"]', '[class*="popup"]', '[class*="dialog"]',
865
+ '[class*="overlay"]', '[class*="alert"]', '[class*="notification"]',
866
+ '[role="dialog"]', '[role="alertdialog"]', '[role="alert"]'
867
+ ];
868
+ const overlays = document.querySelectorAll<HTMLElement>(overlaySelectors.join(','));
869
+ for (const overlay of overlays) {
870
+ if (!isCaptchaText(overlay.innerText || '')) continue;
871
+ const style = overlay.style;
872
+ if (style.display === 'none' || style.visibility === 'hidden') continue;
873
+
874
+ // Try clicking a close button first
875
+ const closeBtn = overlay.querySelector<HTMLElement>(
876
+ 'button, [class*="close"], [aria-label*="lose"], [aria-label*="ismiss"]'
877
+ );
878
+ if (closeBtn) {
879
+ closeBtn.click();
880
+ } else {
881
+ style.display = 'none';
882
+ }
883
+ return true;
884
+ }
885
+
886
+ return false;
887
+ };
888
+
889
+ // Run immediately in case popup is already present
890
+ if (tryDismiss()) return;
891
+
892
+ // Set up persistent watcher — fires on any DOM mutation
893
+ const observer = new MutationObserver(() => {
894
+ if (tryDismiss()) {
895
+ observer.disconnect();
896
+ clearInterval(ticker);
897
+ }
898
+ });
899
+
900
+ // Also poll via interval as safety net (MutationObserver may miss text changes)
901
+ const ticker = setInterval(() => {
902
+ if (tryDismiss()) {
903
+ clearInterval(ticker);
904
+ observer.disconnect();
905
+ }
906
+ }, 400);
907
+
908
+ if (document.body) {
909
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true });
910
+ }
911
+
912
+ // Self-cleanup after 30 seconds to avoid memory leaks
913
+ setTimeout(() => {
914
+ observer.disconnect();
915
+ clearInterval(ticker);
916
+ }, 30000);
917
+ });
918
+
919
+ debug.log('preview', '🔔 CAPTCHA auto-dismiss watcher injected into page');
920
+ } catch {
921
+ // Page closed or navigated away — ignore
922
+ }
923
+ }
924
+
687
925
  /**
688
926
  * Setup browser event handlers
689
927
  */
@@ -31,6 +31,7 @@ import type { Page } from 'puppeteer';
31
31
  import type { BrowserTab, StreamingConfig } from './types';
32
32
  import { DEFAULT_STREAMING_CONFIG } from './types';
33
33
  import { videoEncoderScript } from './scripts/video-stream';
34
+ import { audioCaptureScript } from './scripts/audio-stream';
34
35
  import { debug } from '$shared/utils/logger';
35
36
 
36
37
  interface VideoStreamSession {
@@ -157,7 +158,8 @@ export class BrowserVideoCapture extends EventEmitter {
157
158
  // Inject persistent video encoder script (survives navigation)
158
159
  // Only inject once per page instance
159
160
  if (!videoSession.scriptInjected) {
160
- await page.evaluateOnNewDocument(videoEncoderScript, videoConfig);
161
+ // Temporarily disable evaluateOnNewDocument for evasion test
162
+ // await page.evaluateOnNewDocument(videoEncoderScript, videoConfig);
161
163
  videoSession.scriptInjected = true;
162
164
  debug.log('webcodecs', `Persistent video encoder script injected for ${sessionId}`);
163
165
  }
@@ -166,6 +168,12 @@ export class BrowserVideoCapture extends EventEmitter {
166
168
  // (evaluateOnNewDocument only runs on NEXT navigation)
167
169
  await page.evaluate(videoEncoderScript, videoConfig);
168
170
 
171
+ // Inject audio capture script post-navigation to avoid CF detection.
172
+ // Using page.evaluate() instead of evaluateOnNewDocument() ensures
173
+ // AudioContext patching happens AFTER Cloudflare challenges pass,
174
+ // preventing fingerprint detection of constructor interception.
175
+ await page.evaluate(audioCaptureScript, config.audio);
176
+
169
177
  // Verify peer was created
170
178
  const peerExists = await page.evaluate(() => {
171
179
  return typeof (window as any).__webCodecsPeer?.startStreaming === 'function';
@@ -570,6 +578,9 @@ export class BrowserVideoCapture extends EventEmitter {
570
578
 
571
579
  await page.evaluate(videoEncoderScript, videoConfig);
572
580
 
581
+ // Re-inject audio capture script for new page context (post-navigation)
582
+ await page.evaluate(audioCaptureScript, config.audio);
583
+
573
584
  // Verify peer was re-created
574
585
  const peerExists = await page.evaluate(() => {
575
586
  return typeof (window as any).__webCodecsPeer?.startStreaming === 'function';
@@ -590,6 +601,25 @@ export class BrowserVideoCapture extends EventEmitter {
590
601
  return false;
591
602
  }
592
603
 
604
+ // Re-initialize and start audio capture after navigation
605
+ try {
606
+ const audioReady = await page.evaluate(async () => {
607
+ const encoder = (window as any).__audioEncoder;
608
+ if (!encoder) return false;
609
+ const initiated = await encoder.init();
610
+ if (initiated) return encoder.start();
611
+ return false;
612
+ });
613
+
614
+ if (audioReady) {
615
+ debug.log('webcodecs', 'Audio re-initialized after navigation');
616
+ } else {
617
+ debug.warn('webcodecs', 'Audio not available after navigation, continuing with video only');
618
+ }
619
+ } catch {
620
+ debug.warn('webcodecs', 'Audio re-init failed after navigation, continuing with video only');
621
+ }
622
+
593
623
  // Restart CDP screencast
594
624
  const cdp = (session as any).__webCodecsCdp;
595
625
  if (cdp) {
@@ -631,6 +661,11 @@ export class BrowserVideoCapture extends EventEmitter {
631
661
 
632
662
  if (session?.page && !session.page.isClosed()) {
633
663
  try {
664
+ // Stop audio encoder
665
+ await session.page.evaluate(() => {
666
+ (window as any).__audioEncoder?.stop();
667
+ }).catch(() => {});
668
+
634
669
  // Stop peer
635
670
  await session.page.evaluate(() => {
636
671
  (window as any).__webCodecsPeer?.stopStreaming();
@@ -7,6 +7,9 @@ import type { DatabaseMessage } from '$shared/types/database/schema';
7
7
  * Snapshot domain helper functions
8
8
  */
9
9
 
10
+ /** Sentinel ID for the "initial state" node (before any chat messages) */
11
+ export const INITIAL_NODE_ID = '__initial__';
12
+
10
13
  export interface CheckpointNode {
11
14
  id: string;
12
15
  messageId: string;
@@ -18,6 +21,7 @@ export interface CheckpointNode {
18
21
  isOrphaned: boolean; // descendant of current active checkpoint
19
22
  isCurrent: boolean; // this is the current active checkpoint
20
23
  hasSnapshot: boolean;
24
+ isInitial?: boolean; // true for the "initial state" node
21
25
  senderName?: string | null;
22
26
  // File change statistics (git-like)
23
27
  filesChanged?: number;
@@ -335,62 +339,31 @@ export function isDescendant(
335
339
  }
336
340
 
337
341
  /**
338
- * Get file change stats for a checkpoint by looking at snapshots
339
- * between this checkpoint and the next.
342
+ * Get file change stats for a checkpoint.
343
+ * The snapshot associated with the checkpoint message itself contains the stats
344
+ * (file changes the assistant made in response to this user message).
340
345
  */
341
346
  export function getCheckpointFileStats(
342
- checkpointMsg: DatabaseMessage,
343
- allMessages: DatabaseMessage[],
344
- nextCheckpointTimestamp?: string
347
+ checkpointMsg: DatabaseMessage
345
348
  ): { filesChanged: number; insertions: number; deletions: number } {
346
- let filesChanged = 0;
347
- let insertions = 0;
348
- let deletions = 0;
349
-
350
- const checkpointTimestamp = checkpointMsg.timestamp;
351
-
352
- const laterMessages = allMessages
353
- .filter(m => {
354
- if (m.timestamp <= checkpointTimestamp) return false;
355
- if (nextCheckpointTimestamp && m.timestamp >= nextCheckpointTimestamp) return false;
356
- return true;
357
- })
358
- .sort((a, b) => a.timestamp.localeCompare(b.timestamp));
349
+ const snapshot = snapshotQueries.getByMessageId(checkpointMsg.id);
350
+ if (!snapshot) {
351
+ return { filesChanged: 0, insertions: 0, deletions: 0 };
352
+ }
359
353
 
360
- const allChangedFiles = new Set<string>();
361
- const statsInRange: Array<{ files: number; ins: number; del: number }> = [];
354
+ let filesChanged = snapshot.files_changed || 0;
355
+ const insertions = snapshot.insertions || 0;
356
+ const deletions = snapshot.deletions || 0;
362
357
 
363
- for (const msg of laterMessages) {
358
+ // Try to get a more accurate file count from session_changes
359
+ if (snapshot.session_changes) {
364
360
  try {
365
- const sdkMsg = JSON.parse(msg.sdk_message) as SDKMessage;
366
- if (sdkMsg.type !== 'user') continue;
367
-
368
- const userSnapshot = snapshotQueries.getByMessageId(msg.id);
369
- if (!userSnapshot) continue;
370
-
371
- const fc = userSnapshot.files_changed || 0;
372
- const ins = userSnapshot.insertions || 0;
373
- const del = userSnapshot.deletions || 0;
374
-
375
- if (fc > 0 || ins > 0 || del > 0) {
376
- statsInRange.push({ files: fc, ins, del });
377
- }
378
-
379
- if (userSnapshot.delta_changes) {
380
- try {
381
- const delta = JSON.parse(userSnapshot.delta_changes);
382
- if (delta.added) Object.keys(delta.added).forEach(f => allChangedFiles.add(f));
383
- if (delta.modified) Object.keys(delta.modified).forEach(f => allChangedFiles.add(f));
384
- if (delta.deleted && Array.isArray(delta.deleted)) delta.deleted.forEach((f: string) => allChangedFiles.add(f));
385
- } catch { /* skip */ }
361
+ const changes = JSON.parse(snapshot.session_changes as string);
362
+ const changeCount = Object.keys(changes).length;
363
+ if (changeCount > 0) {
364
+ filesChanged = changeCount;
386
365
  }
387
- } catch { /* skip */ }
388
- }
389
-
390
- if (statsInRange.length > 0) {
391
- filesChanged = allChangedFiles.size > 0 ? allChangedFiles.size : Math.max(...statsInRange.map(s => s.files));
392
- insertions = statsInRange.reduce((sum, s) => sum + s.ins, 0);
393
- deletions = statsInRange.reduce((sum, s) => sum + s.del, 0);
366
+ } catch { /* use files_changed from DB */ }
394
367
  }
395
368
 
396
369
  return { filesChanged, insertions, deletions };