framer-code-link 0.1.3 → 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.
package/src/controller.ts CHANGED
@@ -26,8 +26,23 @@ import {
26
26
  autoResolveConflicts,
27
27
  } from "./helpers/files.js"
28
28
  import { Installer } from "./helpers/installer.js"
29
- import { createHashTracker } from "./utils/hashing.js"
30
- import { info, warn, error, success, debug } from "./utils/logging.js"
29
+ import { createHashTracker } from "./utils/hash-tracker.js"
30
+ import {
31
+ info,
32
+ warn,
33
+ error,
34
+ success,
35
+ debug,
36
+ status,
37
+ fileDown,
38
+ fileUp,
39
+ fileDelete,
40
+ scheduleDisconnectMessage,
41
+ cancelDisconnectMessage,
42
+ didShowDisconnect,
43
+ wasRecentlyDisconnected,
44
+ resetDisconnectState,
45
+ } from "./utils/logging.js"
31
46
  import { hashFileContent } from "./utils/state-persistence.js"
32
47
  import {
33
48
  FileMetadataCache,
@@ -36,6 +51,7 @@ import {
36
51
  import { UserActionCoordinator } from "./helpers/user-actions.js"
37
52
  import { validateIncomingChange } from "./helpers/sync-validator.js"
38
53
  import { findOrCreateProjectDir } from "./utils/project.js"
54
+ import { pluralize, shortProjectHash } from "@code-link/shared"
39
55
 
40
56
  /**
41
57
  * Explicit sync lifecycle modes
@@ -145,7 +161,12 @@ type Effect =
145
161
  | { type: "SEND_MESSAGE"; payload: OutgoingMessage }
146
162
  | { type: "LIST_LOCAL_FILES" }
147
163
  | { type: "DETECT_CONFLICTS"; remoteFiles: FileInfo[] }
148
- | { type: "WRITE_FILES"; files: FileInfo[] }
164
+ | {
165
+ type: "WRITE_FILES"
166
+ files: FileInfo[]
167
+ silent?: boolean
168
+ skipEcho?: boolean // when true, skip writes that match hashTracker (inbound echo)
169
+ }
149
170
  | { type: "DELETE_LOCAL_FILES"; names: string[] }
150
171
  | { type: "REQUEST_CONFLICT_DECISIONS"; conflicts: Conflict[] }
151
172
  | { type: "REQUEST_CONFLICT_VERSIONS"; conflicts: Conflict[] }
@@ -170,6 +191,12 @@ type Effect =
170
191
  requireConfirmation: boolean
171
192
  }
172
193
  | { type: "PERSIST_STATE" }
194
+ | {
195
+ type: "SYNC_COMPLETE"
196
+ totalCount: number
197
+ updatedCount: number
198
+ unchangedCount: number
199
+ }
173
200
  | { type: "LOG"; level: "info" | "debug" | "warn"; message: string }
174
201
 
175
202
  /** Log helper */
@@ -177,6 +204,20 @@ function log(level: "info" | "debug" | "warn", message: string): Effect {
177
204
  return { type: "LOG", level, message }
178
205
  }
179
206
 
207
+ /**
208
+ * Filter out files whose content matches the last remembered hash.
209
+ * Used to skip inbound echoes of our own local sends.
210
+ */
211
+ export function filterEchoedFiles(
212
+ files: FileInfo[],
213
+ hashTracker: ReturnType<typeof createHashTracker>
214
+ ): FileInfo[] {
215
+ return files.filter((file) => {
216
+ if (file.content === undefined) return true
217
+ return !hashTracker.shouldSkip(file.name, file.content)
218
+ })
219
+ }
220
+
180
221
  /**
181
222
  * Pure state transition function
182
223
  * Takes current state + event, returns new state + effects to execute
@@ -214,7 +255,7 @@ function transition(
214
255
 
215
256
  case "FILE_SYNCED": {
216
257
  // Remote confirms they received our local change
217
- effects.push(log("info", `Remote confirmed sync: ${event.fileName}`), {
258
+ effects.push(log("debug", `Remote confirmed sync: ${event.fileName}`), {
218
259
  type: "UPDATE_FILE_METADATA",
219
260
  fileName: event.fileName,
220
261
  remoteModifiedAt: event.remoteModifiedAt,
@@ -226,7 +267,7 @@ function transition(
226
267
  case "DISCONNECT": {
227
268
  effects.push(
228
269
  { type: "PERSIST_STATE" },
229
- log("info", "Disconnected, persisting state")
270
+ log("debug", "Disconnected, persisting state")
230
271
  )
231
272
 
232
273
  if (state.mode === "conflict_resolution") {
@@ -261,7 +302,7 @@ function transition(
261
302
  return { state, effects }
262
303
  }
263
304
 
264
- effects.push(log("info", "Plugin requested file list"), {
305
+ effects.push(log("debug", "Plugin requested file list"), {
265
306
  type: "LIST_LOCAL_FILES",
266
307
  })
267
308
 
@@ -277,7 +318,7 @@ function transition(
277
318
  }
278
319
 
279
320
  effects.push(
280
- log("info", `Received file list: ${event.files.length} files`)
321
+ log("debug", `Received file list: ${event.files.length} files`)
281
322
  )
282
323
 
283
324
  // Detect conflicts between remote snapshot and local files
@@ -310,23 +351,24 @@ function transition(
310
351
 
311
352
  const { conflicts, safeWrites, localOnly } = event
312
353
 
313
- // detectConflicts already did auto-resolution:
354
+ // detectConflicts returns:
314
355
  // - safeWrites = files we can apply (remote-only or local unchanged)
315
- // - conflicts = files that need manual resolution (both sides changed)
356
+ // - conflicts = files that need manual resolution (content or deletion conflicts)
316
357
  // - localOnly = files to upload
358
+ // (unchanged files have metadata recorded in DETECT_CONFLICTS executor)
317
359
 
318
360
  // Apply safe writes
319
361
  if (safeWrites.length > 0) {
320
- effects.push(log("info", `Applying ${safeWrites.length} safe writes`), {
321
- type: "WRITE_FILES",
322
- files: safeWrites,
323
- })
362
+ effects.push(
363
+ log("debug", `Applying ${safeWrites.length} safe writes`),
364
+ { type: "WRITE_FILES", files: safeWrites, silent: true }
365
+ )
324
366
  }
325
367
 
326
368
  // Upload local-only files
327
369
  if (localOnly.length > 0) {
328
370
  effects.push(
329
- log("info", `Uploading ${localOnly.length} local-only files`)
371
+ log("debug", `Uploading ${localOnly.length} local-only files`)
330
372
  )
331
373
  for (const file of localOnly) {
332
374
  effects.push({
@@ -340,16 +382,12 @@ function transition(
340
382
  }
341
383
  }
342
384
 
343
- // If potential conflicts remain, request remote version data before surfacing to user
385
+ // If conflicts remain, request remote version data before surfacing to user
344
386
  if (conflicts.length > 0) {
345
387
  effects.push(
346
388
  log(
347
- "warn",
348
- `${conflicts.length} conflicts require version verification`
349
- ),
350
- log(
351
- "info",
352
- "[CONFLICTS] Requesting remote version data from plugin..."
389
+ "debug",
390
+ `${pluralize(conflicts.length, "conflict")} require version check`
353
391
  ),
354
392
  { type: "REQUEST_CONFLICT_VERSIONS", conflicts }
355
393
  )
@@ -365,9 +403,19 @@ function transition(
365
403
  }
366
404
 
367
405
  // No conflicts - transition to watching
368
- effects.push(log("info", "Initial sync complete, entering watch mode"), {
369
- type: "PERSIST_STATE",
370
- })
406
+ const remoteTotal = state.queuedDiffs.length
407
+ const totalCount = remoteTotal + localOnly.length
408
+ const updatedCount = safeWrites.length + localOnly.length
409
+ const unchangedCount = Math.max(0, remoteTotal - safeWrites.length)
410
+ effects.push(
411
+ { type: "PERSIST_STATE" },
412
+ {
413
+ type: "SYNC_COMPLETE",
414
+ totalCount,
415
+ updatedCount,
416
+ unchangedCount,
417
+ }
418
+ )
371
419
 
372
420
  return {
373
421
  state: {
@@ -415,9 +463,10 @@ function transition(
415
463
  }
416
464
 
417
465
  // Apply the change
418
- effects.push(log("info", `Applying remote change: ${event.file.name}`), {
466
+ effects.push(log("debug", `Applying remote change: ${event.file.name}`), {
419
467
  type: "WRITE_FILES",
420
468
  files: [event.file],
469
+ skipEcho: true,
421
470
  })
422
471
 
423
472
  return { state, effects }
@@ -435,7 +484,7 @@ function transition(
435
484
  // Remote deletes should always be applied immediately
436
485
  // (the file is already gone from Framer)
437
486
  effects.push(
438
- log("info", `Remote delete applied: ${event.fileName}`),
487
+ log("debug", `Remote delete applied: ${event.fileName}`),
439
488
  { type: "DELETE_LOCAL_FILES", names: [event.fileName] },
440
489
  { type: "PERSIST_STATE" }
441
490
  )
@@ -446,7 +495,7 @@ function transition(
446
495
  case "REMOTE_DELETE_CONFIRMED": {
447
496
  // User confirmed the delete - apply it
448
497
  effects.push(
449
- log("info", `Delete confirmed: ${event.fileName}`),
498
+ log("debug", `Delete confirmed: ${event.fileName}`),
450
499
  { type: "DELETE_LOCAL_FILES", names: [event.fileName] },
451
500
  { type: "PERSIST_STATE" }
452
501
  )
@@ -456,7 +505,7 @@ function transition(
456
505
 
457
506
  case "REMOTE_DELETE_CANCELLED": {
458
507
  // User cancelled - restore the file
459
- effects.push(log("info", `Delete cancelled: ${event.fileName}`))
508
+ effects.push(log("debug", `Delete cancelled: ${event.fileName}`))
460
509
  effects.push({
461
510
  type: "WRITE_FILES",
462
511
  files: [
@@ -485,41 +534,74 @@ function transition(
485
534
 
486
535
  // User picked one resolution for ALL conflicts
487
536
  if (event.resolution === "remote") {
488
- // Apply all remote versions
489
- const remoteFiles = state.pendingConflicts.map((c) => ({
490
- name: c.fileName,
491
- content: c.remoteContent,
492
- modifiedAt: c.remoteModifiedAt,
493
- }))
494
-
537
+ // Apply all remote versions (or delete locally if remote is null)
538
+ for (const conflict of state.pendingConflicts) {
539
+ if (conflict.remoteContent === null) {
540
+ // Remote deleted this file - delete locally
541
+ effects.push({
542
+ type: "DELETE_LOCAL_FILES",
543
+ names: [conflict.fileName],
544
+ })
545
+ } else {
546
+ effects.push({
547
+ type: "WRITE_FILES",
548
+ files: [
549
+ {
550
+ name: conflict.fileName,
551
+ content: conflict.remoteContent,
552
+ modifiedAt: conflict.remoteModifiedAt,
553
+ },
554
+ ],
555
+ })
556
+ }
557
+ }
495
558
  effects.push(
496
- log("info", `Applying ${remoteFiles.length} remote versions`),
497
- { type: "WRITE_FILES", files: remoteFiles }
559
+ log(
560
+ "debug",
561
+ `Applied ${state.pendingConflicts.length} remote versions`
562
+ )
498
563
  )
499
564
  } else {
500
- // Send all local versions
565
+ // Send all local versions (or delete from Framer if local is null)
566
+ for (const conflict of state.pendingConflicts) {
567
+ if (conflict.localContent === null) {
568
+ // Local deleted this file - delete from Framer
569
+ effects.push({
570
+ type: "SEND_MESSAGE",
571
+ payload: {
572
+ type: "file-delete",
573
+ fileNames: [conflict.fileName],
574
+ },
575
+ })
576
+ } else {
577
+ effects.push({
578
+ type: "SEND_MESSAGE",
579
+ payload: {
580
+ type: "file-change",
581
+ fileName: conflict.fileName,
582
+ content: conflict.localContent,
583
+ },
584
+ })
585
+ }
586
+ }
501
587
  effects.push(
502
588
  log(
503
- "info",
504
- `Applying ${state.pendingConflicts.length} local versions`
589
+ "debug",
590
+ `Applied ${state.pendingConflicts.length} local versions`
505
591
  )
506
592
  )
507
- for (const conflict of state.pendingConflicts) {
508
- effects.push({
509
- type: "SEND_MESSAGE",
510
- payload: {
511
- type: "file-change",
512
- fileName: conflict.fileName,
513
- content: conflict.localContent,
514
- },
515
- })
516
- }
517
593
  }
518
594
 
519
595
  // All conflicts resolved - transition to watching
520
- effects.push(log("info", "All conflicts resolved, entering watch mode"), {
521
- type: "PERSIST_STATE",
522
- })
596
+ effects.push(
597
+ { type: "PERSIST_STATE" },
598
+ {
599
+ type: "SYNC_COMPLETE",
600
+ totalCount: state.pendingConflicts.length,
601
+ updatedCount: state.pendingConflicts.length,
602
+ unchangedCount: 0,
603
+ }
604
+ )
523
605
 
524
606
  const { pendingConflicts: _discarded, ...rest } = state
525
607
  return {
@@ -556,7 +638,6 @@ function transition(
556
638
  return { state, effects }
557
639
  }
558
640
 
559
- effects.push(log("info", `Local change detected: ${relativePath}`))
560
641
  effects.push({
561
642
  type: "SEND_LOCAL_CHANGE",
562
643
  fileName: relativePath,
@@ -566,7 +647,7 @@ function transition(
566
647
  }
567
648
 
568
649
  case "delete": {
569
- effects.push(log("info", `Local delete detected: ${relativePath}`), {
650
+ effects.push(log("debug", `Local delete detected: ${relativePath}`), {
570
651
  type: "REQUEST_LOCAL_DELETE_DECISION",
571
652
  fileName: relativePath,
572
653
  requireConfirmation: true, // Will be overridden by config in effect
@@ -595,41 +676,64 @@ function transition(
595
676
  if (autoResolvedLocal.length > 0) {
596
677
  effects.push(
597
678
  log(
598
- "info",
599
- `[AUTO-RESOLVE] Applying ${autoResolvedLocal.length} local changes`
679
+ "debug",
680
+ `Auto-resolved ${autoResolvedLocal.length} local changes`
600
681
  )
601
682
  )
602
683
  for (const conflict of autoResolvedLocal) {
603
- effects.push({
604
- type: "SEND_LOCAL_CHANGE",
605
- fileName: conflict.fileName,
606
- content: conflict.localContent,
607
- })
684
+ if (conflict.localContent === null) {
685
+ // Local deleted - delete from Framer
686
+ effects.push({
687
+ type: "SEND_MESSAGE",
688
+ payload: {
689
+ type: "file-delete",
690
+ fileNames: [conflict.fileName],
691
+ },
692
+ })
693
+ } else {
694
+ effects.push({
695
+ type: "SEND_LOCAL_CHANGE",
696
+ fileName: conflict.fileName,
697
+ content: conflict.localContent,
698
+ })
699
+ }
608
700
  }
609
701
  }
610
702
 
611
703
  if (autoResolvedRemote.length > 0) {
612
704
  effects.push(
613
705
  log(
614
- "info",
615
- `[AUTO-RESOLVE] Applying ${autoResolvedRemote.length} remote changes`
616
- ),
617
- {
618
- type: "WRITE_FILES",
619
- files: autoResolvedRemote.map((conflict) => ({
620
- name: conflict.fileName,
621
- content: conflict.remoteContent,
622
- modifiedAt: conflict.remoteModifiedAt ?? Date.now(),
623
- })),
624
- }
706
+ "debug",
707
+ `Auto-resolved ${autoResolvedRemote.length} remote changes`
708
+ )
625
709
  )
710
+ for (const conflict of autoResolvedRemote) {
711
+ if (conflict.remoteContent === null) {
712
+ // Remote deleted - delete locally
713
+ effects.push({
714
+ type: "DELETE_LOCAL_FILES",
715
+ names: [conflict.fileName],
716
+ })
717
+ } else {
718
+ effects.push({
719
+ type: "WRITE_FILES",
720
+ files: [
721
+ {
722
+ name: conflict.fileName,
723
+ content: conflict.remoteContent,
724
+ modifiedAt: conflict.remoteModifiedAt ?? Date.now(),
725
+ },
726
+ ],
727
+ })
728
+ }
729
+ }
626
730
  }
627
731
 
628
732
  if (remainingConflicts.length > 0) {
629
733
  effects.push(
630
734
  log(
631
735
  "warn",
632
- `[AUTO-RESOLVE] ${remainingConflicts.length} conflicts require user resolution`
736
+ `${pluralize(remainingConflicts.length, "conflict")} require resolution`
633
737
  ),
634
738
  { type: "REQUEST_CONFLICT_DECISIONS", conflicts: remainingConflicts }
635
739
  )
@@ -643,9 +747,16 @@ function transition(
643
747
  }
644
748
  }
645
749
 
646
- effects.push(log("info", "[AUTO-RESOLVE] All conflicts auto-resolved!"), {
647
- type: "PERSIST_STATE",
648
- })
750
+ const resolvedCount = autoResolvedLocal.length + autoResolvedRemote.length
751
+ effects.push(
752
+ { type: "PERSIST_STATE" },
753
+ {
754
+ type: "SYNC_COMPLETE",
755
+ totalCount: resolvedCount,
756
+ updatedCount: resolvedCount,
757
+ unchangedCount: 0,
758
+ }
759
+ )
649
760
 
650
761
  const { pendingConflicts: _discarded, ...rest } = state
651
762
  return {
@@ -701,7 +812,7 @@ async function executeEffect(
701
812
  config.explicitDir
702
813
  )
703
814
  config.filesDir = `${config.projectDir}/files`
704
- info(`Files directory: ${config.filesDir}`)
815
+ debug(`Files directory: ${config.filesDir}`)
705
816
 
706
817
  // Create files directory
707
818
  await fs.mkdir(config.filesDir, { recursive: true })
@@ -712,7 +823,7 @@ async function executeEffect(
712
823
  case "LOAD_PERSISTED_STATE": {
713
824
  if (config.projectDir) {
714
825
  await fileMetadataCache.initialize(config.projectDir)
715
- info(`Loaded persisted metadata for ${fileMetadataCache.size()} files`)
826
+ debug(`Loaded persisted metadata for ${fileMetadataCache.size()} files`)
716
827
  }
717
828
  return []
718
829
  }
@@ -741,12 +852,22 @@ async function executeEffect(
741
852
  }
742
853
 
743
854
  // Use existing helper to detect conflicts
744
- const { conflicts, writes, localOnly } = await detectConflicts(
855
+ const { conflicts, writes, localOnly, unchanged } = await detectConflicts(
745
856
  effect.remoteFiles,
746
857
  config.filesDir,
747
858
  { persistedState: fileMetadataCache.getPersistedState() }
748
859
  )
749
860
 
861
+ // Record metadata for unchanged files so watcher add events get skipped
862
+ // (chokidar ignoreInitial=false fires late adds that would otherwise re-upload)
863
+ for (const file of unchanged) {
864
+ fileMetadataCache.recordRemoteWrite(
865
+ file.name,
866
+ file.content,
867
+ file.modifiedAt ?? Date.now()
868
+ )
869
+ }
870
+
750
871
  // Return CONFLICTS_DETECTED event to continue the flow
751
872
  return [
752
873
  {
@@ -772,13 +893,32 @@ async function executeEffect(
772
893
 
773
894
  case "WRITE_FILES": {
774
895
  if (config.filesDir) {
896
+ // skipEcho is opt-in: some callers still need side-effects (metadata/logs)
897
+ // even when content matches the last hash tracked in-memory.
898
+ const filesToWrite =
899
+ effect.skipEcho === true
900
+ ? filterEchoedFiles(effect.files, hashTracker)
901
+ : effect.files
902
+
903
+ if (effect.skipEcho && filesToWrite.length !== effect.files.length) {
904
+ const skipped = effect.files.length - filesToWrite.length
905
+ debug(`Skipped ${pluralize(skipped, "echoed change")}`)
906
+ }
907
+
908
+ if (filesToWrite.length === 0) {
909
+ return []
910
+ }
911
+
775
912
  await writeRemoteFiles(
776
- effect.files,
913
+ filesToWrite,
777
914
  config.filesDir,
778
915
  hashTracker,
779
916
  installer ?? undefined
780
917
  )
781
- for (const file of effect.files) {
918
+ for (const file of filesToWrite) {
919
+ if (!effect.silent) {
920
+ fileDown(file.name)
921
+ }
782
922
  const remoteTimestamp = file.modifiedAt ?? Date.now()
783
923
  fileMetadataCache.recordRemoteWrite(
784
924
  file.name,
@@ -794,6 +934,7 @@ async function executeEffect(
794
934
  if (config.filesDir) {
795
935
  for (const fileName of effect.names) {
796
936
  await deleteLocalFile(fileName, config.filesDir, hashTracker)
937
+ fileDelete(fileName)
797
938
  fileMetadataCache.recordDelete(fileName)
798
939
  }
799
940
  }
@@ -824,8 +965,8 @@ async function executeEffect(
824
965
  }
825
966
  })
826
967
 
827
- info(
828
- `[CONFLICTS] Requesting remote version data for ${versionRequests.length} file(s)`
968
+ debug(
969
+ `Requesting remote version data for ${pluralize(versionRequests.length, "file")}`
829
970
  )
830
971
 
831
972
  await sendMessage(syncState.socket, {
@@ -873,10 +1014,24 @@ async function executeEffect(
873
1014
  }
874
1015
 
875
1016
  case "SEND_LOCAL_CHANGE": {
1017
+ const contentHash = hashFileContent(effect.content)
1018
+ const metadata = fileMetadataCache.get(effect.fileName)
1019
+
1020
+ // Skip if file matches last confirmed remote content
1021
+ if (metadata?.lastSyncedHash === contentHash) {
1022
+ debug(
1023
+ `Skipping local change for ${effect.fileName}: matches last synced content`
1024
+ )
1025
+ return []
1026
+ }
1027
+
876
1028
  // Echo prevention: skip if we just wrote this exact content
877
1029
  if (hashTracker.shouldSkip(effect.fileName, effect.content)) {
878
1030
  return []
879
1031
  }
1032
+
1033
+ debug(`Local change detected: ${effect.fileName}`)
1034
+
880
1035
  try {
881
1036
  // Send change to plugin
882
1037
  if (syncState.socket) {
@@ -885,6 +1040,7 @@ async function executeEffect(
885
1040
  fileName: effect.fileName,
886
1041
  content: effect.content,
887
1042
  })
1043
+ fileUp(effect.fileName)
888
1044
  }
889
1045
 
890
1046
  // Only remember hash after successful send (prevents re-sending on failure)
@@ -895,10 +1051,7 @@ async function executeEffect(
895
1051
  installer.process(effect.fileName, effect.content)
896
1052
  }
897
1053
  } catch (err) {
898
- console.warn(
899
- `Failed to push change for ${effect.fileName}, will re-sync on next diff:`,
900
- err
901
- )
1054
+ warn(`Failed to push ${effect.fileName}`)
902
1055
  }
903
1056
 
904
1057
  return []
@@ -946,6 +1099,33 @@ async function executeEffect(
946
1099
  return []
947
1100
  }
948
1101
 
1102
+ case "SYNC_COMPLETE": {
1103
+ const wasDisconnected = wasRecentlyDisconnected()
1104
+
1105
+ // Notify plugin that sync is complete
1106
+ if (syncState.socket) {
1107
+ await sendMessage(syncState.socket, { type: "sync-complete" })
1108
+ }
1109
+
1110
+ if (wasDisconnected) {
1111
+ // Only show reconnect message if we actually showed the disconnect notice
1112
+ if (didShowDisconnect()) {
1113
+ success(
1114
+ `Reconnected, synced ${effect.totalCount} files (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`
1115
+ )
1116
+ status("Watching for changes...")
1117
+ }
1118
+ resetDisconnectState()
1119
+ return []
1120
+ }
1121
+
1122
+ success(
1123
+ `Synced ${effect.totalCount} files (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`
1124
+ )
1125
+ status("Watching for changes...")
1126
+ return []
1127
+ }
1128
+
949
1129
  case "LOG": {
950
1130
  const logFn =
951
1131
  effect.level === "info" ? info : effect.level === "warn" ? warn : debug
@@ -959,9 +1139,7 @@ async function executeEffect(
959
1139
  * Starts the sync controller with the given configuration
960
1140
  */
961
1141
  export async function start(config: Config): Promise<void> {
962
- info("🚀 Starting Code Link")
963
- info(`Project: ${config.projectHash}`)
964
- info(`Port: ${config.port} (auto-selected from project hash)`)
1142
+ status("Waiting for Plugin connection...")
965
1143
 
966
1144
  const hashTracker = createHashTracker()
967
1145
  const fileMetadataCache = new FileMetadataCache()
@@ -982,7 +1160,7 @@ export async function start(config: Config): Promise<void> {
982
1160
  // Process events through state machine and execute effects recursively
983
1161
  async function processEvent(event: SyncEvent) {
984
1162
  const socketState = syncState.socket?.readyState
985
- info(
1163
+ debug(
986
1164
  `[STATE] Processing event: ${event.type} (mode: ${syncState.mode}, socket: ${socketState ?? "none"})`
987
1165
  )
988
1166
 
@@ -990,7 +1168,7 @@ export async function start(config: Config): Promise<void> {
990
1168
  syncState = result.state
991
1169
 
992
1170
  if (result.effects.length > 0) {
993
- info(
1171
+ debug(
994
1172
  `[STATE] Event produced ${result.effects.length} effects: ${result.effects.map((e) => e.type).join(", ")}`
995
1173
  )
996
1174
  }
@@ -1000,7 +1178,7 @@ export async function start(config: Config): Promise<void> {
1000
1178
  // Check socket state before each effect
1001
1179
  const currentSocketState = syncState.socket?.readyState
1002
1180
  if (currentSocketState !== undefined && currentSocketState !== 1) {
1003
- warn(
1181
+ debug(
1004
1182
  `[STATE] Socket not open (state: ${currentSocketState}) before executing ${effect.type}`
1005
1183
  )
1006
1184
  }
@@ -1026,13 +1204,14 @@ export async function start(config: Config): Promise<void> {
1026
1204
 
1027
1205
  // Handle initial handshake
1028
1206
  connection.on("handshake", async (client: WebSocket, message) => {
1029
- info("Received handshake from plugin")
1030
- info(`Project: ${message.projectName} (${message.projectId})`)
1207
+ debug(`Received handshake: ${message.projectName} (${message.projectId})`)
1031
1208
 
1032
- // Validate project hash
1033
- if (message.projectId !== config.projectHash) {
1209
+ // Validate project hash (normalize both to short hash for comparison)
1210
+ const expectedShort = shortProjectHash(config.projectHash)
1211
+ const receivedShort = shortProjectHash(message.projectId)
1212
+ if (receivedShort !== expectedShort) {
1034
1213
  warn(
1035
- `Project ID mismatch: expected ${config.projectHash}, got ${message.projectId}`
1214
+ `Project ID mismatch: expected ${expectedShort}, got ${receivedShort}`
1036
1215
  )
1037
1216
  client.close()
1038
1217
  return
@@ -1056,7 +1235,15 @@ export async function start(config: Config): Promise<void> {
1056
1235
  startWatcher()
1057
1236
  }
1058
1237
 
1059
- success("Handshake successful - connection established")
1238
+ // Cancel any pending disconnect message (fast reconnect)
1239
+ cancelDisconnectMessage()
1240
+
1241
+ // Only show "Connected" on initial connection, not reconnects
1242
+ // Reconnect confirmation happens in SYNC_COMPLETE
1243
+ const wasDisconnected = wasRecentlyDisconnected()
1244
+ if (!wasDisconnected && !didShowDisconnect()) {
1245
+ success(`Connected to ${message.projectName}`)
1246
+ }
1060
1247
  })
1061
1248
 
1062
1249
  // Message Handler
@@ -1080,8 +1267,8 @@ export async function start(config: Config): Promise<void> {
1080
1267
  (sum, f) => sum + (f.content?.length ?? 0),
1081
1268
  0
1082
1269
  )
1083
- info(
1084
- `[FILE_LIST] Received ${message.files.length} files (${(totalSize / 1024).toFixed(1)}KB total)`
1270
+ debug(
1271
+ `Received file list: ${message.files.length} files (${(totalSize / 1024).toFixed(1)}KB)`
1085
1272
  )
1086
1273
  event = { type: "FILE_LIST", files: message.files }
1087
1274
  break
@@ -1188,10 +1375,12 @@ export async function start(config: Config): Promise<void> {
1188
1375
  })
1189
1376
 
1190
1377
  connection.on("disconnect", async () => {
1191
- warn("Plugin disconnected")
1378
+ // Schedule disconnect message with delay - if reconnect happens quickly, we skip it
1379
+ scheduleDisconnectMessage(() => {
1380
+ status("Disconnected, waiting to reconnect...")
1381
+ })
1192
1382
  await processEvent({ type: "DISCONNECT" })
1193
1383
  userActions.cleanup()
1194
- info("Will perform full diff on reconnect")
1195
1384
  })
1196
1385
 
1197
1386
  connection.on("error", (err) => {
@@ -1212,14 +1401,12 @@ export async function start(config: Config): Promise<void> {
1212
1401
  })
1213
1402
  }
1214
1403
 
1215
- // Lifecycle
1216
-
1217
- info("✓ Controller initialized and ready")
1218
- info(`Waiting for plugin connection on port ${config.port}...`)
1404
+ // Lifecycle - no additional startup messages needed (banner shown in index.ts)
1219
1405
 
1220
1406
  // Graceful shutdown
1221
1407
  process.on("SIGINT", async () => {
1222
- info("\nShutting down gracefully...")
1408
+ console.log() // newline after ^C
1409
+ status("Shutting down...")
1223
1410
  if (watcher) {
1224
1411
  await watcher.close()
1225
1412
  }